Merge branch 'main' of github.com:boudra/paseo

This commit is contained in:
Mohamed Boudra
2026-01-29 12:20:33 +07:00
106 changed files with 9267 additions and 792 deletions
+21
View File
@@ -0,0 +1,21 @@
# Dependencies
node_modules
# Build outputs
dist
.next
.expo
build
*.tsbuildinfo
# Coverage
coverage
# Lock files
*.lock
package-lock.json
# Generated
android
ios
.turbo
+7
View File
@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}
+85 -1
View File
@@ -14,7 +14,8 @@
"packages/app", "packages/app",
"packages/relay", "packages/relay",
"packages/website", "packages/website",
"packages/desktop" "packages/desktop",
"packages/cli"
], ],
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11" "@anthropic-ai/claude-agent-sdk": "^0.2.11"
@@ -24,6 +25,7 @@
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"knip": "^5.82.1", "knip": "^5.82.1",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"prettier": "^3.5.3",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
@@ -6698,6 +6700,10 @@
"resolved": "packages/app", "resolved": "packages/app",
"link": true "link": true
}, },
"node_modules/@paseo/cli": {
"resolved": "packages/cli",
"link": true
},
"node_modules/@paseo/desktop": { "node_modules/@paseo/desktop": {
"resolved": "packages/desktop", "resolved": "packages/desktop",
"link": true "link": true
@@ -9270,6 +9276,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/minimist": { "node_modules/@types/minimist": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
@@ -26235,6 +26248,19 @@
} }
} }
}, },
"node_modules/zx": {
"version": "8.8.5",
"resolved": "https://registry.npmjs.org/zx/-/zx-8.8.5.tgz",
"integrity": "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"zx": "build/cli.js"
},
"engines": {
"node": ">= 12.17.0"
}
},
"packages/app": { "packages/app": {
"name": "@paseo/app", "name": "@paseo/app",
"version": "1.0.0", "version": "1.0.0",
@@ -26344,6 +26370,64 @@
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
}, },
"packages/cli": {
"name": "@paseo/cli",
"version": "0.1.0",
"dependencies": {
"@paseo/server": "*",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
"ws": "^8.14.2",
"yaml": "^2.8.2"
},
"bin": {
"paseo": "bin/paseo"
},
"devDependencies": {
"@types/mime-types": "^3.0.1",
"@types/ws": "^8.5.8",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"zx": "^8.8.5"
}
},
"packages/cli/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"packages/cli/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/cli/node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"packages/desktop": { "packages/desktop": {
"name": "@paseo/desktop", "name": "@paseo/desktop",
"version": "1.0.0", "version": "1.0.0",
+5 -1
View File
@@ -7,7 +7,8 @@
"packages/app", "packages/app",
"packages/relay", "packages/relay",
"packages/website", "packages/website",
"packages/desktop" "packages/desktop",
"packages/cli"
], ],
"scripts": { "scripts": {
"dev": "./scripts/dev.sh", "dev": "./scripts/dev.sh",
@@ -17,6 +18,8 @@
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "npm run start --workspace=@paseo/server", "start": "npm run start --workspace=@paseo/server",
"android": "npm run android --workspace=@paseo/app", "android": "npm run android --workspace=@paseo/app",
"android:release": "ANDROID_VARIANT=productionRelease npm run android --workspace=@paseo/app", "android:release": "ANDROID_VARIANT=productionRelease npm run android --workspace=@paseo/app",
@@ -29,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"prettier": "^3.5.3",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
"knip": "^5.82.1", "knip": "^5.82.1",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
@@ -246,7 +246,6 @@ function normalizeAgentSnapshot(
requiresAttention: snapshot.requiresAttention ?? false, requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null, attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp, attentionTimestamp,
parentAgentId: snapshot.parentAgentId,
archivedAt, archivedAt,
}; };
} }
@@ -62,11 +62,6 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
} }
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
for (const agent of agents.values()) { for (const agent of agents.values()) {
// Use agent's own lastActivityAt field directly
// Skip child agents - only show root agents on homepage
if (agent.parentAgentId) {
continue;
}
const nextAgent: AggregatedAgent = { const nextAgent: AggregatedAgent = {
id: agent.id, id: agent.id,
serverId, serverId,
@@ -79,7 +74,6 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
requiresAttention: agent.requiresAttention, requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason, attentionReason: agent.attentionReason,
attentionTimestamp: agent.attentionTimestamp, attentionTimestamp: agent.attentionTimestamp,
parentAgentId: agent.parentAgentId,
archivedAt: agent.archivedAt, archivedAt: agent.archivedAt,
}; };
allAgents.push(nextAgent); allAgents.push(nextAgent);
-2
View File
@@ -98,7 +98,6 @@ export interface Agent {
requiresAttention?: boolean; requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null; attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null; attentionTimestamp?: Date | null;
parentAgentId?: string | null;
archivedAt?: Date | null; archivedAt?: Date | null;
} }
@@ -839,7 +838,6 @@ export const useSessionStore = create<SessionStore>()(
requiresAttention: agent.requiresAttention ?? false, requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null, attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp ?? null, attentionTimestamp: agent.attentionTimestamp ?? null,
parentAgentId: agent.parentAgentId,
}); });
} }
return entries; return entries;
@@ -12,6 +12,5 @@ export interface AgentDirectoryEntry {
requiresAttention?: boolean; requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null; attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null; attentionTimestamp?: Date | null;
parentAgentId?: string | null;
archivedAt?: Date | null; archivedAt?: Date | null;
} }
@@ -16,7 +16,6 @@ function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
requiresAttention: overrides.requiresAttention ?? false, requiresAttention: overrides.requiresAttention ?? false,
attentionReason: overrides.attentionReason ?? null, attentionReason: overrides.attentionReason ?? null,
attentionTimestamp: overrides.attentionTimestamp ?? null, attentionTimestamp: overrides.attentionTimestamp ?? null,
parentAgentId: overrides.parentAgentId ?? null,
} as AggregatedAgent; } as AggregatedAgent;
} }
@@ -1162,12 +1162,8 @@ const TOOL_NAME_MAP: Record<string, string> = {
read_file: "Read", read_file: "Read",
apply_patch: "Edit", apply_patch: "Edit",
paseo_worktree_setup: "Setup", paseo_worktree_setup: "Setup",
"agent-control.set_title": "Set title",
"agent-control.set_branch": "Set branch",
set_title: "Set title", set_title: "Set title",
set_branch: "Set branch", set_branch: "Set branch",
"mcp__agent-control__set_title": "Set title",
"mcp__agent-control__set_branch": "Set branch",
thinking: "Thinking", thinking: "Thinking",
}; };
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env npx tsx
import '../src/index.js'
+543
View File
@@ -0,0 +1,543 @@
# Output Architecture Design
This document describes the output abstraction layer for the Paseo CLI, enabling structured data output with multiple format options.
## Overview
Commands should return **structured data objects**, not formatted strings. A separate rendering layer transforms this data into the requested output format. This separation enables:
1. **Testability** - Tests verify structured data without parsing strings
2. **Flexibility** - Easy to add new output formats
3. **Consistency** - Uniform formatting across all commands
### Inspiration from Existing CLIs
This design draws from patterns in established CLIs:
- **Docker CLI** - Uses Go templates with `--format` flag, provides `table` and `json` directives
- **kubectl** - Supports `-o json`, `-o yaml`, `-o wide`, and custom columns
- **GitHub CLI** - Uses `--json` with field selection, plus `--jq` and `--template` post-processors
Sources:
- [Docker CLI Formatting](https://docs.docker.com/engine/cli/formatting/)
- [kubectl Output Formatting](https://www.baeldung.com/ops/kubectl-output-format)
- [GitHub CLI Formatting](https://cli.github.com/manual/gh_help_formatting)
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Command Execution │
│ │
│ parseArgs() → executeCommand() → CommandResult<T> │
└─────────────────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Output Renderer │
│ │
│ CommandResult<T> + OutputOptions → formatted string │
│ │
│ Renderers: │
│ - TableRenderer (default, human-readable) │
│ - JsonRenderer (machine-readable) │
│ - YamlRenderer (machine-readable) │
│ - QuietRenderer (minimal, IDs only) │
└─────────────────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ stdout/stderr │
└─────────────────────────────────────────────────────────────┘
```
## Type Definitions
### Output Options
```typescript
type OutputFormat = 'table' | 'json' | 'yaml'
interface OutputOptions {
format: OutputFormat
quiet: boolean // Minimal output (IDs only)
noHeaders: boolean // Omit table headers
noColor: boolean // Disable color output
}
```
### Command Result
Commands return a `CommandResult<T>` that contains structured data plus metadata for formatting:
```typescript
interface CommandResult<T> {
/** The structured data to render */
data: T
/** Schema describing how to render this data */
schema: OutputSchema<T>
}
interface OutputSchema<T> {
/** Field to use for quiet mode (--quiet outputs just this) */
idField: keyof T | ((item: T) => string)
/** Column definitions for table output */
columns: ColumnDef<T>[]
/** Optional: transform data before JSON/YAML output */
serialize?: (data: T) => unknown
}
interface ColumnDef<T> {
/** Header text for the column */
header: string
/** Field key or accessor function */
field: keyof T | ((item: T) => unknown)
/** Optional width hint (characters) */
width?: number
/** Optional alignment */
align?: 'left' | 'right' | 'center'
/** Optional color function */
color?: (value: unknown, item: T) => string | undefined
}
```
### Single vs List Results
Commands may return either a single item or a list:
```typescript
// For commands returning a single item (e.g., `agent show <id>`)
interface SingleResult<T> extends CommandResult<T> {
type: 'single'
data: T
}
// For commands returning a list (e.g., `agent list`)
interface ListResult<T> extends CommandResult<T[]> {
type: 'list'
data: T[]
}
// Union type for command handlers
type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
```
## Example: Agent List Command
### Data Type
```typescript
interface AgentListItem {
id: string
title: string
status: 'running' | 'idle' | 'error'
provider: string
cwd: string
createdAt: string
}
```
### Schema Definition
```typescript
const agentListSchema: OutputSchema<AgentListItem> = {
idField: 'id',
columns: [
{
header: 'ID',
field: 'id',
width: 8,
},
{
header: 'TITLE',
field: 'title',
width: 30,
},
{
header: 'STATUS',
field: 'status',
color: (value) => {
switch (value) {
case 'running': return 'green'
case 'idle': return 'dim'
case 'error': return 'red'
default: return undefined
}
},
},
{
header: 'PROVIDER',
field: 'provider',
},
{
header: 'CWD',
field: 'cwd',
},
],
}
```
### Command Implementation
```typescript
async function agentListCommand(options: CommandOptions): Promise<ListResult<AgentListItem>> {
const client = await connectToDaemon(options)
const agents = client.listAgents()
const data = agents.map(agent => ({
id: agent.agentId,
title: agent.title ?? '(untitled)',
status: mapLifecycleStatus(agent.lifecycle),
provider: agent.agentType,
cwd: agent.cwd,
createdAt: agent.createdAt,
}))
return {
type: 'list',
data,
schema: agentListSchema,
}
}
```
## Renderer Implementations
### Table Renderer
The default renderer for human-readable output:
```typescript
function renderTable<T>(result: ListResult<T>, options: OutputOptions): string {
const { data, schema } = result
if (data.length === 0) {
return '' // Or a "no items" message
}
const rows: string[][] = []
// Add header row (unless noHeaders)
if (!options.noHeaders) {
rows.push(schema.columns.map(col => col.header))
}
// Add data rows
for (const item of data) {
const row = schema.columns.map(col => {
const value = typeof col.field === 'function'
? col.field(item)
: item[col.field]
return String(value ?? '')
})
rows.push(row)
}
// Calculate column widths
const widths = schema.columns.map((col, i) => {
const maxContent = Math.max(...rows.map(row => stripAnsi(row[i]).length))
return col.width ? Math.max(col.width, maxContent) : maxContent
})
// Format and join
return rows.map((row, rowIndex) => {
return row.map((cell, colIndex) => {
const col = schema.columns[colIndex]
const width = widths[colIndex]
let formatted = padCell(cell, width, col.align ?? 'left')
// Apply color (skip header row)
if (rowIndex > 0 && col.color && !options.noColor) {
const colorName = col.color(cell, data[rowIndex - 1])
if (colorName) {
formatted = applyColor(formatted, colorName)
}
}
return formatted
}).join(' ')
}).join('\n')
}
```
### JSON Renderer
```typescript
function renderJson<T>(result: AnyCommandResult<T>, options: OutputOptions): string {
const { data, schema } = result
const output = schema.serialize ? schema.serialize(data) : data
return JSON.stringify(output, null, 2)
}
```
### YAML Renderer
```typescript
import YAML from 'yaml'
function renderYaml<T>(result: AnyCommandResult<T>, options: OutputOptions): string {
const { data, schema } = result
const output = schema.serialize ? schema.serialize(data) : data
return YAML.stringify(output)
}
```
### Quiet Renderer
Returns only the ID field(s):
```typescript
function renderQuiet<T>(result: AnyCommandResult<T>, options: OutputOptions): string {
const { data, schema } = result
const getId = typeof schema.idField === 'function'
? schema.idField
: (item: T) => String(item[schema.idField as keyof T])
if (result.type === 'single') {
return getId(data as T)
}
return (data as T[]).map(getId).join('\n')
}
```
## Error Output
Errors are handled separately from success output and always go to stderr:
```typescript
interface CommandError {
code: string // Machine-readable error code
message: string // Human-readable message
details?: unknown // Additional context
}
function renderError(error: CommandError, options: OutputOptions): string {
if (options.format === 'json') {
return JSON.stringify({ error }, null, 2)
}
if (options.format === 'yaml') {
return YAML.stringify({ error })
}
// Table/default format
return chalk.red(`Error: ${error.message}`)
}
```
## Streaming Output
For commands like `logs -f` and `attach`, streaming requires a different approach:
```typescript
interface StreamingResult<T> {
type: 'stream'
schema: OutputSchema<T>
/** Async iterator yielding items as they arrive */
stream: AsyncIterable<T>
}
```
### Streaming Renderer
```typescript
async function renderStream<T>(
result: StreamingResult<T>,
options: OutputOptions,
write: (chunk: string) => void
): Promise<void> {
const { stream, schema } = result
// For JSON, output newline-delimited JSON (NDJSON)
if (options.format === 'json') {
for await (const item of stream) {
write(JSON.stringify(item) + '\n')
}
return
}
// For table format, render each item as a row
let headerWritten = false
for await (const item of stream) {
if (!headerWritten && !options.noHeaders) {
write(renderTableHeader(schema) + '\n')
headerWritten = true
}
write(renderTableRow(item, schema, options) + '\n')
}
}
```
### NDJSON for Streaming
When `--format json` is used with streaming commands, output is newline-delimited JSON (NDJSON) for easy parsing:
```
{"timestamp":"2024-01-15T10:30:00Z","type":"stdout","content":"Hello"}
{"timestamp":"2024-01-15T10:30:01Z","type":"stdout","content":"World"}
```
This allows consumers to process output line-by-line without buffering the entire stream.
## Testing
### Testing Structured Data
Tests can directly verify the structured data without parsing formatted output:
```typescript
describe('agent list', () => {
it('returns agents with correct structure', async () => {
const result = await agentListCommand({ host: testHost })
expect(result.type).toBe('list')
expect(result.data).toHaveLength(2)
expect(result.data[0]).toMatchObject({
id: expect.any(String),
title: 'Test Agent',
status: 'running',
})
})
it('uses correct schema for table output', async () => {
const result = await agentListCommand({ host: testHost })
expect(result.schema.idField).toBe('id')
expect(result.schema.columns.map(c => c.header)).toEqual([
'ID', 'TITLE', 'STATUS', 'PROVIDER', 'CWD'
])
})
})
```
### Testing Renderers
Renderer tests verify formatting independently:
```typescript
describe('table renderer', () => {
it('formats data as aligned table', () => {
const result: ListResult<AgentListItem> = {
type: 'list',
data: [
{ id: 'abc123', title: 'Agent 1', status: 'running', ... },
{ id: 'def456', title: 'Agent 2', status: 'idle', ... },
],
schema: agentListSchema,
}
const output = renderTable(result, { format: 'table', quiet: false, ... })
expect(output).toContain('ID')
expect(output).toContain('abc123')
expect(output).toContain('Agent 1')
})
})
```
### E2E Tests
E2E tests can verify both structured data (for correctness) and formatted output (for UX):
```typescript
// Verify JSON output is valid and contains expected data
test('agent list --format json', async () => {
const output = await ctx.paseo('agent list --format json')
const data = JSON.parse(output.stdout)
expect(data).toBeInstanceOf(Array)
expect(data[0]).toHaveProperty('id')
})
// Verify table output looks correct
test('agent list shows table headers', async () => {
const output = await ctx.paseo('agent list')
expect(output.stdout).toMatch(/ID\s+TITLE\s+STATUS/)
})
```
## Integration with Command Framework
### Global Options
Add output options to the root command:
```typescript
program
.option('-f, --format <format>', 'Output format: table, json, yaml', 'table')
.option('-q, --quiet', 'Minimal output (IDs only)')
.option('--no-headers', 'Omit table headers')
.option('--no-color', 'Disable colored output')
```
### Command Handler Wrapper
A wrapper function handles the rendering:
```typescript
function withOutput<T>(
handler: (options: CommandOptions) => Promise<AnyCommandResult<T>>
) {
return async (options: CommandOptions) => {
try {
const result = await handler(options)
const output = render(result, options)
process.stdout.write(output + '\n')
} catch (error) {
const errorOutput = renderError(toCommandError(error), options)
process.stderr.write(errorOutput + '\n')
process.exit(1)
}
}
}
// Usage
program
.command('list')
.description('List agents')
.action(withOutput(agentListCommand))
```
## Implementation Plan
1. **Phase 1: Core Types**
- Define `CommandResult`, `OutputSchema`, `ColumnDef` types
- Implement basic table renderer
- Implement JSON renderer
2. **Phase 2: Integration**
- Add global output options to CLI
- Create `withOutput` wrapper
- Migrate `daemon status` command as proof of concept
3. **Phase 3: Full Coverage**
- Add YAML renderer
- Add quiet renderer
- Migrate all existing commands
4. **Phase 4: Streaming**
- Implement `StreamingResult` type
- Add streaming renderers
- Apply to `logs` and `attach` commands
## Open Questions
1. **Should we support Go templates like Docker/gh?** This adds flexibility but also complexity. For v1, predefined formats are likely sufficient.
2. **How to handle nested data in tables?** Options:
- Flatten (e.g., `config.timeout` becomes `TIMEOUT` column)
- Skip in table, include in JSON/YAML
- Use nested tables for detail views
3. **Should quiet mode support custom fields?** e.g., `--quiet=title` to output titles instead of IDs.
+155
View File
@@ -0,0 +1,155 @@
# CLI Type Audit (commands)
## Scope
- Audited `packages/cli/src/commands/**` for inline type/interface definitions.
- Checked `@paseo/server` exports from `packages/server/src/server/exports.ts`.
- Note: `packages/server/src/index.ts` does **not** exist in this repo; the package export entrypoint is `./src/server/exports.ts` per `packages/server/package.json`.
## Server Exports (current)
`packages/server/src/server/exports.ts` exports:
- `createPaseoDaemon`, `PaseoDaemon`, `PaseoDaemonConfig`
- `loadConfig`, `resolvePaseoHome`
- `createRootLogger`, `LogLevel`, `LogFormat`
- `loadPersistedConfig`, `PersistedConfig`
- `DaemonClientV2`, `DaemonClientV2Config`, `ConnectionState`, `DaemonEvent`
No agent snapshot/timeline/permission/message types are exported.
## Findings by File
### `packages/cli/src/commands/agent/run.ts`
Inline types:
- `AgentSnapshot` (id/provider/cwd/createdAt/status/title)
Recommended server type:
- `AgentSnapshotPayload` from `packages/server/src/shared/messages.ts` (daemon client returns this shape). **Not exported** from `@paseo/server` today.
Notes:
- `AgentRunResult` is CLI output; no server type expected.
---
### `packages/cli/src/commands/agent/ps.ts`
Inline types:
- `AgentSnapshot` (id/provider/cwd/createdAt/status/title/archivedAt?)
Recommended server type:
- `AgentSnapshotPayload` (includes `archivedAt` and full snapshot fields). **Not exported**.
Notes:
- `AgentListItem` is CLI output; no server type expected.
---
### `packages/cli/src/commands/agent/send.ts`
Inline types:
- `AgentSnapshot` (id/provider/cwd/createdAt/status/title)
Recommended server type:
- `AgentSnapshotPayload`. **Not exported**.
Notes:
- `AgentSendResult` is CLI output; no server type expected.
---
### `packages/cli/src/commands/agent/inspect.ts`
Inline types:
- `AgentSnapshotLike` (snapshot fields + `lastUsage`, `capabilities`, `availableModes`, `pendingPermissions`)
Recommended server types:
- `AgentSnapshotPayload` (overall snapshot shape). **Not exported**.
- `AgentUsage` for `lastUsage`. **Not exported** (in `packages/server/src/server/agent/agent-sdk-types.ts`).
- `AgentCapabilityFlags` for `capabilities`. **Not exported**.
- `AgentMode` for `availableModes`. **Not exported**.
- `AgentPermissionRequest` for `pendingPermissions`. **Not exported**.
Notes:
- `pendingPermissions` uses `{ id, tool?: string }` but server type is `AgentPermissionRequest` with `{ name, kind, ... }`; current CLI projection is lossy and field names don’t match (`tool` vs `name`).
- `AgentInspect` and `InspectRow` are CLI output types.
---
### `packages/cli/src/commands/agent/logs.ts`
Inline types:
- `AgentStreamSnapshotMessage`
- `AgentStreamMessage`
- Timeline item shape in `formatTimelineItem` and `extractTimelineFrom*` helpers (`{ type: string; ... }`)
Recommended server types:
- `AgentStreamSnapshotMessage` from `packages/server/src/shared/messages.ts`. **Not exported**.
- `AgentStreamMessage` from `packages/server/src/shared/messages.ts`. **Not exported**.
- `AgentStreamEventPayload` from `packages/server/src/shared/messages.ts` (for `event` typing). **Not exported**.
- `AgentTimelineItem` from `packages/server/src/server/agent/agent-sdk-types.ts` (for timeline item shape). **Not exported**.
Notes:
- These are WebSocket message types; they should come from shared message definitions to avoid drift.
- `LogEntry` is CLI output.
---
### `packages/cli/src/commands/agent/mode.ts`
Inline types:
- `ModeListItem` (id/label/description)
- `SetModeResult` (agentId/mode)
Recommended server type:
- `ModeListItem` duplicates the shape of `AgentMode` (id/label/description) from `packages/server/src/server/agent/agent-sdk-types.ts`. **Not exported**.
Notes:
- `SetModeResult` is CLI output.
---
### `packages/cli/src/commands/daemon/start.ts`
Inline types:
- `StartOptions` (CLI flags)
Server type usage:
- CLI-only; no server type expected.
---
### `packages/cli/src/commands/daemon/status.ts`
Inline types:
- `DaemonStatus`
- `StatusRow`
Server type usage:
- CLI-only; no server type expected.
---
### `packages/cli/src/commands/daemon/restart.ts`
Inline types:
- `RestartResult`
Server type usage:
- CLI-only; no server type expected.
---
### `packages/cli/src/commands/daemon/stop.ts`
Inline types:
- `StopResult`
Server type usage:
- CLI-only; no server type expected.
## Gaps in `@paseo/server` Exports (needed for CLI cleanup)
To replace inline types in CLI commands, `@paseo/server` would need to export (directly or re-export):
- From `packages/server/src/shared/messages.ts`:
- `AgentSnapshotPayload`
- `AgentStreamEventPayload`
- `AgentStreamMessage`
- `AgentStreamSnapshotMessage`
- (optionally) `AgentStateMessage`, `SessionStateMessage`, `SessionOutboundMessage` if CLI starts typing daemon event queues more strictly
- From `packages/server/src/server/agent/agent-sdk-types.ts`:
- `AgentMode`
- `AgentUsage`
- `AgentCapabilityFlags`
- `AgentPermissionRequest`
- `AgentTimelineItem`
## Summary
Primary inline types that should become server imports are the agent snapshot/timeline/message/permission/mode shapes in `agent/*` commands. All are defined in server shared or agent SDK types today but are not exported through `@paseo/server`.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@paseo/cli",
"version": "0.1.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"bin": {
"paseo": "./bin/paseo"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test:e2e": "npx zx tests/run-all.ts",
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
},
"dependencies": {
"@paseo/server": "*",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
"ws": "^8.14.2",
"yaml": "^2.8.2"
},
"devDependencies": {
"@types/mime-types": "^3.0.1",
"@types/ws": "^8.5.8",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"zx": "^8.8.5"
}
}
+146
View File
@@ -0,0 +1,146 @@
import { Command } from 'commander'
import { createAgentCommand } from './commands/agent/index.js'
import { createDaemonCommand } from './commands/daemon/index.js'
import { createPermitCommand } from './commands/permit/index.js'
import { createProviderCommand } from './commands/provider/index.js'
import { createWorktreeCommand } from './commands/worktree/index.js'
import { runLsCommand } from './commands/agent/ls.js'
import { runRunCommand } from './commands/agent/run.js'
import { runLogsCommand } from './commands/agent/logs.js'
import { runStopCommand } from './commands/agent/stop.js'
import { runSendCommand } from './commands/agent/send.js'
import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { withOutput } from './output/index.js'
const VERSION = '0.1.0'
// Helper function to collect multiple option values into an array
function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value])
}
export function createCli(): Command {
const program = new Command()
program
.name('paseo')
.description('Paseo CLI - control your AI coding agents from the command line')
.version(VERSION, '-v, --version', 'output the version number')
// Global output options
.option('-f, --format <format>', 'output format: table, json, yaml', 'table')
.option('-q, --quiet', 'minimal output (IDs only)')
.option('--no-headers', 'omit table headers')
.option('--no-color', 'disable colored output')
// Primary agent commands (top-level)
program
.command('ls')
.description('List agents. By default shows background agents (without ui=true) in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
program
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--worktree <name>', 'Create agent in a new git worktree')
.option('--base <branch>', 'Base branch for worktree (default: current branch)')
.option('--image <path>', 'Attach image(s) to the initial prompt (can be used multiple times)', collectMultiple, [])
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
program
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(runAttachCommand)
program
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(runLogsCommand)
program
.command('stop')
.description('Stop an agent (cancel if running, then terminate)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
program
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runSendCommand))
program
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((id, options, command) => {
if (options.json) {
command.parent.opts().format = 'json'
}
return withOutput(runInspectCommand)(id, options, command)
})
program
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand())
// Daemon commands
program.addCommand(createDaemonCommand())
// Permission commands
program.addCommand(createPermitCommand())
// Provider commands
program.addCommand(createProviderCommand())
// Worktree commands
program.addCommand(createWorktreeCommand())
return program
}
+140
View File
@@ -0,0 +1,140 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result type for agent archive command */
export interface AgentArchiveResult {
agentId: string
status: 'archived'
archivedAt: string
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<AgentArchiveResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'STATUS', field: 'status' },
{ header: 'ARCHIVED AT', field: 'archivedAt' },
],
}
export interface AgentArchiveOptions extends CommandOptions {
force?: boolean
host?: string
}
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>
export async function runArchiveCommand(
agentIdArg: string,
options: AgentArchiveOptions,
_command: Command
): Promise<AgentArchiveCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent archive <id>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID (supports prefix matching)
const agentId = resolveAgentId(agentIdArg, agents)
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Get the agent snapshot to check status
const agent = agents.find((a: AgentSnapshotPayload) => a.id === agentId)
if (!agent) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Check if agent is already archived
if (agent.archivedAt) {
const error: CommandError = {
code: 'AGENT_ALREADY_ARCHIVED',
message: `Agent ${agentId.slice(0, 7)} is already archived`,
details: `Archived at: ${agent.archivedAt}`,
}
throw error
}
// Check if agent is running and reject unless --force is set
if (agent.status === 'running' && !options.force) {
const error: CommandError = {
code: 'AGENT_RUNNING',
message: `Agent ${agentId.slice(0, 7)} is currently running`,
details: 'Use --force to archive a running agent, or stop it first with: paseo agent stop',
}
throw error
}
// Archive the agent
const result = await client.archiveAgent(agentId)
await client.close()
return {
type: 'single',
data: {
agentId,
status: 'archived',
archivedAt: result.archivedAt,
},
schema: archiveSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'ARCHIVE_FAILED',
message: `Failed to archive agent: ${message}`,
}
throw error
}
}
+219
View File
@@ -0,0 +1,219 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type {
DaemonClientV2,
AgentStreamMessage,
AgentStreamSnapshotMessage,
AgentStreamEventPayload,
AgentTimelineItem,
} from '@paseo/server'
export interface AgentAttachOptions {
host?: string
[key: string]: unknown
}
/**
* Format and print a timeline item to the terminal
*/
function printTimelineItem(item: AgentTimelineItem): void {
switch (item.type) {
case 'assistant_message':
// Print assistant text directly
process.stdout.write(item.text)
break
case 'reasoning':
// Print reasoning in a muted color if available
console.log(`\n[Reasoning] ${item.text}`)
break
case 'tool_call': {
const toolName = item.name
const status = item.status ?? 'started'
console.log(`\n[Tool: ${toolName}] ${status}`)
break
}
case 'todo': {
const completed = item.items.filter((i) => i.completed).length
const total = item.items.length
console.log(`\n[Todo] ${completed}/${total} completed`)
break
}
case 'error':
console.error(`\n[Error] ${item.message}`)
break
case 'user_message':
console.log(`\n[User] ${item.text}`)
break
default:
// Unknown item type, skip
break
}
}
/**
* Format and print a stream event to the terminal
*/
function printStreamEvent(event: AgentStreamEventPayload): void {
switch (event.type) {
case 'timeline':
// Print the timeline item
printTimelineItem(event.item)
break
case 'permission_requested':
console.log(`\n[Permission Required] ${event.request.name}`)
if (event.request.description) {
console.log(` ${event.request.description}`)
}
break
case 'permission_resolved':
console.log(`\n[Permission ${event.resolution.behavior}]`)
break
case 'turn_failed':
console.error(`\n[Turn Failed] ${event.error}`)
break
case 'attention_required':
console.log(`\n[Attention Required: ${event.reason}]`)
break
default:
// Other event types (thread_started, provider_event, etc.) are internal
break
}
}
/**
* Attach to a running agent's output stream
*/
export async function runAttachCommand(
id: string,
options: AgentAttachOptions,
_command: Command
): Promise<void> {
const host = getDaemonHost({ host: options.host as string | undefined })
if (!id) {
console.error('Error: Agent ID required')
console.error('Usage: paseo attach <id>')
process.exit(1)
}
let client: DaemonClientV2
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
console.error('Start the daemon with: paseo daemon start')
process.exit(1)
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait for session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
const resolvedId = resolveAgentId(id, agents)
if (!resolvedId) {
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
}
const agent = agents.find((a) => a.id === resolvedId)
if (!agent) {
console.error(`Error: Agent not found: ${resolvedId}`)
await client.close()
process.exit(1)
}
// Print header
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
console.log(`(Press Ctrl+C to detach)\n`)
// Get existing output from snapshot
const snapshotPromise = new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), 5000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
if (message.payload.agentId !== resolvedId) return
clearTimeout(timeout)
unsubscribe()
// Print recent events from snapshot
for (const e of message.payload.events) {
printStreamEvent(e.event)
}
resolve()
})
})
// Initialize agent to trigger snapshot
try {
await client.initializeAgent(resolvedId)
} catch {
// Agent might already be initialized, continue
}
// Wait for snapshot
await snapshotPromise
// Subscribe to new events
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage
if (message.type !== 'agent_stream') return
if (message.payload.agentId !== resolvedId) return
printStreamEvent(message.payload.event)
})
// Handle Ctrl+C to detach gracefully
let detached = false
const detach = () => {
if (detached) return
detached = true
console.log('\n\nDetaching from agent...')
unsubscribe()
client
.close()
.then(() => {
process.exit(0)
})
.catch(() => {
process.exit(1)
})
}
process.on('SIGINT', detach)
process.on('SIGTERM', detach)
// Keep the process alive
await new Promise(() => {
// Wait indefinitely until interrupted
})
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Failed to attach to agent: ${message}`)
process.exit(1)
}
}
+123
View File
@@ -0,0 +1,123 @@
import { Command } from 'commander'
import { runModeCommand } from './mode.js'
import { runArchiveCommand } from './archive.js'
import { runLsCommand } from './ls.js'
import { runRunCommand } from './run.js'
import { runLogsCommand } from './logs.js'
import { runStopCommand } from './stop.js'
import { runSendCommand } from './send.js'
import { runInspectCommand } from './inspect.js'
import { runWaitCommand } from './wait.js'
import { runAttachCommand } from './attach.js'
import { withOutput } from '../../output/index.js'
export function createAgentCommand(): Command {
const agent = new Command('agent').description('Manage agents (advanced operations)')
// Helper function to collect multiple option values into an array
const collectMultiple = (value: string, previous: string[]): string[] => {
return previous.concat([value])
}
// Primary agent commands (same as top-level)
agent
.command('ls')
.description('List agents. By default shows background agents (without ui=true) in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
agent
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
agent
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(runAttachCommand)
agent
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(runLogsCommand)
agent
.command('stop')
.description('Stop an agent (cancel if running, then terminate)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
agent
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runSendCommand))
agent
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runInspectCommand))
agent
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
agent
.command('mode')
.description("Change an agent's operational mode")
.argument('<id>', 'Agent ID (or prefix)')
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runModeCommand))
agent
.command('archive')
.description('Archive an agent (soft-delete)')
.argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))
return agent
}
+274
View File
@@ -0,0 +1,274 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Agent inspect data for display (matches CLI spec format) */
interface AgentInspect {
Id: string
Name: string
Provider: string
Model: string
Status: string
Mode: string
Cwd: string
CreatedAt: string
UpdatedAt: string
LastUsage: {
InputTokens: number
OutputTokens: number
CachedTokens: number
CostUsd: number
} | null
Capabilities: {
Streaming: boolean
Persistence: boolean
DynamicModes: boolean
McpServers: boolean
} | null
AvailableModes: Array<{
id: string
label: string
}> | null
PendingPermissions: Array<{
id: string
tool: string
}>
Worktree: string | null
ParentAgentId: string | null
}
/** Key-value row for table display */
interface InspectRow {
key: string
value: string
}
/** Schema for key-value display with custom serialization for JSON/YAML */
function createInspectSchema(agent: AgentInspect): OutputSchema<InspectRow> {
return {
idField: 'key',
columns: [
{ header: 'KEY', field: 'key' },
{
header: 'VALUE',
field: 'value',
color: (_, item) => {
if (item.key === 'Status') {
if (item.value === 'running') return 'green'
if (item.value === 'idle') return 'yellow'
if (item.value === 'error') return 'red'
}
return undefined
},
},
],
// For JSON/YAML, return the structured agent object
serialize: (_item) => agent,
}
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
}
return path
}
/** Format cost in USD */
function formatCost(costUsd: number): string {
if (costUsd === 0) return '$0.00'
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`
return `$${costUsd.toFixed(2)}`
}
/** Convert agent snapshot to inspection data */
function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
const lastUsage = snapshot.lastUsage
? {
InputTokens: snapshot.lastUsage.inputTokens ?? 0,
OutputTokens: snapshot.lastUsage.outputTokens ?? 0,
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
}
: null
const capabilities = snapshot.capabilities
? {
Streaming: snapshot.capabilities.supportsStreaming ?? false,
Persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
}
: null
// Extract worktree and parentAgentId from labels if they exist
const worktree = snapshot.labels?.['paseo.worktree'] ?? null
const parentAgentId = snapshot.labels?.['paseo.parent-agent-id'] ?? null
return {
Id: snapshot.id,
Name: snapshot.title ?? '-',
Provider: snapshot.provider,
Model: snapshot.model ?? '-',
Status: snapshot.status,
Mode: snapshot.currentModeId ?? 'default',
Cwd: snapshot.cwd,
CreatedAt: snapshot.createdAt,
UpdatedAt: snapshot.updatedAt,
LastUsage: lastUsage,
Capabilities: capabilities,
AvailableModes: snapshot.availableModes
? snapshot.availableModes.map((m) => ({ id: m.id, label: m.label }))
: null,
PendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
id: p.id,
tool: p.name ?? 'unknown',
})),
Worktree: worktree,
ParentAgentId: parentAgentId,
}
}
/** Convert agent to key-value rows for table display */
function toInspectRows(agent: AgentInspect): InspectRow[] {
const rows: InspectRow[] = [
{ key: 'Id', value: agent.Id },
{ key: 'Name', value: agent.Name },
{ key: 'Provider', value: agent.Provider },
{ key: 'Model', value: agent.Model },
{ key: 'Status', value: agent.Status },
{ key: 'Mode', value: agent.Mode },
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
{ key: 'CreatedAt', value: agent.CreatedAt },
{ key: 'UpdatedAt', value: agent.UpdatedAt },
]
if (agent.LastUsage) {
rows.push({
key: 'LastUsage',
value: `InputTokens: ${agent.LastUsage.InputTokens}, OutputTokens: ${agent.LastUsage.OutputTokens}, CachedTokens: ${agent.LastUsage.CachedTokens}, CostUsd: ${formatCost(agent.LastUsage.CostUsd)}`,
})
}
if (agent.Capabilities) {
rows.push({
key: 'Capabilities',
value: `Streaming: ${agent.Capabilities.Streaming}, Persistence: ${agent.Capabilities.Persistence}, DynamicModes: ${agent.Capabilities.DynamicModes}, McpServers: ${agent.Capabilities.McpServers}`,
})
}
if (agent.AvailableModes && agent.AvailableModes.length > 0) {
rows.push({
key: 'AvailableModes',
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(', '),
})
}
rows.push({
key: 'PendingPermissions',
value: agent.PendingPermissions.length > 0
? agent.PendingPermissions.map(p => `${p.id} (${p.tool})`).join(', ')
: '[]'
})
rows.push({ key: 'Worktree', value: agent.Worktree ?? 'null' })
rows.push({ key: 'ParentAgentId', value: agent.ParentAgentId ?? 'null' })
return rows
}
export type AgentInspectResult = ListResult<InspectRow>
export interface AgentInspectOptions extends CommandOptions {
host?: string
}
export async function runInspectCommand(
agentIdArg: string,
options: AgentInspectOptions,
_command: Command
): Promise<AgentInspectResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent inspect <id>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID (supports prefix matching)
const agentId = resolveAgentId(agentIdArg, agents)
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Get the full agent snapshot
const snapshot = agents.find((a) => a.id === agentId)
if (!snapshot) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
await client.close()
const inspectData = toInspectData(snapshot)
return {
type: 'list',
data: toInspectRows(inspectData),
schema: createInspectSchema(inspectData),
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INSPECT_FAILED',
message: `Failed to inspect agent: ${message}`,
}
throw error
}
}
+269
View File
@@ -0,0 +1,269 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions } from '../../output/index.js'
import type {
DaemonClientV2,
AgentStreamMessage,
AgentStreamSnapshotMessage,
AgentTimelineItem,
} from '@paseo/server'
import { curateAgentActivity } from '@paseo/server'
export interface AgentLogsOptions extends CommandOptions {
follow?: boolean
tail?: string
filter?: string
since?: string
}
// Logs command returns void - it outputs directly to console
export type AgentLogsResult = void
/**
* Check if a timeline item matches the filter type
*/
function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
if (!filter) return true
const filterLower = filter.toLowerCase()
const type = item.type.toLowerCase()
switch (filterLower) {
case 'tools':
return type === 'tool_call'
case 'text':
return type === 'user_message' || type === 'assistant_message' || type === 'reasoning'
case 'errors':
return type === 'error'
case 'permissions':
// Permissions might be in tool_call status or a separate event type
return type.includes('permission')
default:
// If filter doesn't match predefined types, match against the actual type
return type.includes(filterLower)
}
}
/**
* Extract timeline items from an agent_stream_snapshot message
*/
function extractTimelineFromSnapshot(message: AgentStreamSnapshotMessage): AgentTimelineItem[] {
const items: AgentTimelineItem[] = []
for (const e of message.payload.events) {
if (e.event.type === 'timeline') {
items.push(e.event.item)
}
}
return items
}
/**
* Extract a timeline item from an agent_stream message
*/
function extractTimelineFromStream(message: AgentStreamMessage): AgentTimelineItem | null {
if (message.payload.event.type === 'timeline') {
return message.payload.event.item
}
return null
}
export async function runLogsCommand(
id: string,
options: AgentLogsOptions,
_command: Command
): Promise<AgentLogsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
if (!id) {
console.error('Error: Agent ID required')
console.error('Usage: paseo agent logs <id>')
process.exit(1)
}
let client: DaemonClientV2
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
console.error('Start the daemon with: paseo daemon start')
process.exit(1)
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait for session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
const resolvedId = resolveAgentId(id, agents)
if (!resolvedId) {
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
}
// For follow mode, we stream events continuously
if (options.follow) {
await runFollowMode(client, resolvedId, options)
return
}
// For non-follow mode, initialize the agent to get timeline snapshot
// Set up handler for timeline events before initializing
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
if (message.payload.agentId !== resolvedId) return
clearTimeout(timeout)
unsubscribe()
resolve(extractTimelineFromSnapshot(message))
})
})
// Initialize agent to trigger timeline snapshot
try {
await client.initializeAgent(resolvedId)
} catch {
// Agent might already be initialized, continue to collect from queue
}
// Get timeline from snapshot
let timelineItems = await snapshotPromise
// Also check message queue for any stream events
const queue = client.getMessageQueue()
for (const msg of queue) {
if (msg.type === 'agent_stream') {
const streamMsg = msg as AgentStreamMessage
if (streamMsg.payload.agentId === resolvedId) {
const item = extractTimelineFromStream(streamMsg)
if (item) {
timelineItems.push(item)
}
}
}
}
// Apply filter
if (options.filter) {
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
}
// Apply tail limit
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
timelineItems = timelineItems.slice(-tailCount)
}
}
await client.close()
// Use curateAgentActivity to format the transcript
const transcript = curateAgentActivity(timelineItems)
console.log(transcript)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Failed to get logs: ${message}`)
await client.close().catch(() => {})
process.exit(1)
}
}
/**
* Follow mode: stream logs in real-time until interrupted
*/
async function runFollowMode(
client: DaemonClientV2,
agentId: string,
options: AgentLogsOptions
): Promise<void> {
// First, get existing timeline
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000)
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
const message = msg as AgentStreamSnapshotMessage
if (message.type !== 'agent_stream_snapshot') return
if (message.payload.agentId !== agentId) return
clearTimeout(timeout)
unsubscribe()
resolve(extractTimelineFromSnapshot(message))
})
})
// Initialize agent to trigger timeline snapshot
try {
await client.initializeAgent(agentId)
} catch {
// Agent might already be initialized
}
// Get existing timeline
let existingItems = await snapshotPromise
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
}
// Apply tail to existing items
if (options.tail) {
const tailCount = parseInt(options.tail, 10)
if (!isNaN(tailCount) && tailCount > 0) {
existingItems = existingItems.slice(-tailCount)
}
}
// Print existing transcript
const existingTranscript = curateAgentActivity(existingItems)
if (existingTranscript !== 'No activity to display.') {
console.log(existingTranscript)
}
// Subscribe to new events
console.log('\n--- Following logs (Ctrl+C to stop) ---\n')
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage
if (message.type !== 'agent_stream') return
if (message.payload.agentId !== agentId) return
if (message.payload.event.type === 'timeline') {
const item = message.payload.event.item
// Apply filter
if (options.filter && !matchesFilter(item, options.filter)) {
return
}
// Print each timeline item as it arrives using the curator format
const transcript = curateAgentActivity([item])
if (transcript !== 'No activity to display.') {
console.log(transcript)
}
}
})
// Wait for interrupt
await new Promise<void>((resolve) => {
const cleanup = () => {
unsubscribe()
resolve()
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
})
await client.close()
}
+224
View File
@@ -0,0 +1,224 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Agent list item for display */
export interface AgentListItem {
id: string
shortId: string
name: string
provider: string
status: string
cwd: string
created: string
}
/** Helper to get relative time string */
function relativeTime(date: Date | string): string {
const now = Date.now()
const then = new Date(date).getTime()
const seconds = Math.floor((now - then) / 1000)
if (seconds < 60) return 'just now'
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
return `${Math.floor(seconds / 86400)} days ago`
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
}
return path
}
/** Schema for agent ls output */
export const agentLsSchema: OutputSchema<AgentListItem> = {
idField: 'shortId',
columns: [
{ header: 'AGENT ID', field: 'shortId', width: 12 },
{ header: 'NAME', field: 'name', width: 20 },
{ header: 'PROVIDER', field: 'provider', width: 15 },
{
header: 'STATUS',
field: 'status',
width: 10,
color: (value) => {
if (value === 'running') return 'green'
if (value === 'idle') return 'yellow'
if (value === 'error') return 'red'
return undefined
},
},
{ header: 'CWD', field: 'cwd', width: 30 },
{ header: 'CREATED', field: 'created', width: 15 },
],
}
/** Transform agent snapshot to AgentListItem */
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
return {
id: agent.id,
shortId: agent.id.slice(0, 7),
name: agent.title ?? '-',
provider: agent.model ? `${agent.provider}/${agent.model}` : agent.provider,
status: agent.status,
cwd: shortenPath(agent.cwd),
created: relativeTime(agent.createdAt),
}
}
export type AgentLsResult = ListResult<AgentListItem>
export interface AgentLsOptions extends CommandOptions {
/** -a: Include all statuses (not just running/idle) */
all?: boolean
/** -g: Show agents globally (not just current directory) */
global?: boolean
/** Filter by specific status */
status?: string
/** Filter by specific cwd (overrides default cwd filtering) */
cwd?: string
/** Filter by labels (key=value format) */
label?: string[]
/** Filter to UI agents only (equivalent to --label ui=true) */
ui?: boolean
}
/**
* Agent ls command with correct semantics from design doc:
* - `paseo agent ls` → running/idle agents in current directory
* - `paseo agent ls -a` → all statuses in current directory
* - `paseo agent ls -g` → running/idle agents globally
* - `paseo agent ls -ag` → everything everywhere
*/
export async function runLsCommand(
options: AgentLsOptions,
_command: Command
): Promise<AgentLsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request and wait for session state to get agent information
await client.waitForSessionState()
let agents = client.listAgents()
// Status filtering:
// By default, only show running/idle agents (not error, archived, etc.)
// With -a flag, show all statuses
if (!options.all) {
agents = agents.filter((a) => {
// Show running and idle agents, exclude archived
return (a.status === 'running' || a.status === 'idle') && !a.archivedAt
})
}
// If explicit status filter is provided, use it
if (options.status) {
agents = agents.filter((a) => a.status === options.status)
}
// Directory filtering:
// By default, only show agents in current working directory
// With -g flag, show agents globally (all directories)
if (!options.global) {
const currentCwd = options.cwd ?? process.cwd()
agents = agents.filter((a) => {
// Normalize paths for comparison
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = currentCwd.replace(/\/$/, '')
// Match exact cwd or subdirectories
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
}
// Label filtering:
// Parse --label flags and --ui flag
// --ui is equivalent to --label ui=true
// By default (no --ui flag), show background agents (those WITHOUT ui=true)
const labelFilters: Record<string, string> = {}
if (options.label) {
for (const labelStr of options.label) {
const eqIndex = labelStr.indexOf('=')
if (eqIndex !== -1) {
const key = labelStr.slice(0, eqIndex)
const value = labelStr.slice(eqIndex + 1)
labelFilters[key] = value
}
}
}
// Add ui=true filter if --ui flag is set
if (options.ui) {
labelFilters['ui'] = 'true'
}
// Apply label filtering
if (Object.keys(labelFilters).length > 0) {
// Filter to agents that have ALL specified labels (AND semantics)
agents = agents.filter((a) => {
const agentLabels = (a as any).labels as Record<string, string> | undefined
for (const [key, value] of Object.entries(labelFilters)) {
if (!agentLabels || agentLabels[key] !== value) {
return false
}
}
return true
})
} else {
// Default: show background agents only (those without ui=true)
agents = agents.filter((a) => {
const agentLabels = (a as any).labels as Record<string, string> | undefined
return !agentLabels || agentLabels['ui'] !== 'true'
})
}
await client.close()
// Sort agents: running first, then idle, then others; within each group, most recent first
const statusOrder = { running: 0, idle: 1 } as Record<string, number>
agents.sort((a, b) => {
// Primary sort: by status
const aOrder = statusOrder[a.status] ?? 999
const bOrder = statusOrder[b.status] ?? 999
if (aOrder !== bOrder) return aOrder - bOrder
// Secondary sort: by creation time (most recent first)
const aTime = new Date(a.createdAt).getTime()
const bTime = new Date(b.createdAt).getTime()
return bTime - aTime
})
const items = agents.map(toListItem)
return {
type: 'list',
data: items,
schema: agentLsSchema,
}
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'LIST_AGENTS_FAILED',
message: `Failed to list agents: ${message}`,
}
throw error
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type {
CommandOptions,
OutputSchema,
CommandError,
AnyCommandResult,
} from '../../output/index.js'
import type { AgentMode } from '@paseo/server'
/** Result for setting mode */
export interface SetModeResult {
agentId: string
mode: string
}
/** Schema for mode list output */
export const modeListSchema: OutputSchema<AgentMode> = {
idField: 'id',
columns: [
{ header: 'MODE', field: 'id', width: 15 },
{ header: 'LABEL', field: 'label', width: 25 },
{ header: 'DESCRIPTION', field: 'description', width: 40 },
],
}
/** Schema for set mode output */
export const setModeSchema: OutputSchema<SetModeResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'MODE', field: 'mode', width: 20 },
],
}
export interface AgentModeOptions extends CommandOptions {
list?: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AgentModeResult = AnyCommandResult<any>
export async function runModeCommand(
id: string,
mode: string | undefined,
options: AgentModeOptions,
_command: Command
): Promise<AgentModeResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!options.list && !mode) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Mode argument required unless --list is specified',
details: 'Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID
const resolvedId = resolveAgentId(id, agents)
if (!resolvedId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
}
const agent = agents.find((a) => a.id === resolvedId)
if (!agent) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found after resolution: ${resolvedId}`,
}
throw error
}
if (options.list) {
// List available modes for this agent
const availableModes = agent.availableModes ?? []
await client.close()
const items: AgentMode[] = availableModes.map((m) => ({
id: m.id,
label: m.label,
description: m.description,
}))
return {
type: 'list',
data: items,
schema: modeListSchema,
}
} else {
// Set the agent mode
await client.setAgentMode(resolvedId, mode!)
await client.close()
return {
type: 'single',
data: {
agentId: resolvedId.slice(0, 7),
mode: mode!,
},
schema: setModeSchema,
}
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw if it's already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'MODE_OPERATION_FAILED',
message: `Failed to ${options.list ? 'list modes' : 'set mode'}: ${message}`,
}
throw error
}
}
+187
View File
@@ -0,0 +1,187 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { lookup } from 'mime-types'
/** Result type for agent run command */
export interface AgentRunResult {
agentId: string
status: 'created' | 'running'
provider: string
cwd: string
title: string | null
}
/** Schema for agent run output */
export const agentRunSchema: OutputSchema<AgentRunResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 10 },
{ header: 'PROVIDER', field: 'provider', width: 10 },
{ header: 'CWD', field: 'cwd', width: 30 },
{ header: 'TITLE', field: 'title', width: 20 },
],
}
export interface AgentRunOptions extends CommandOptions {
detach?: boolean
name?: string
provider?: string
model?: string
mode?: string
worktree?: string
base?: string
image?: string[]
cwd?: string
label?: string[]
ui?: boolean
}
function toRunResult(agent: AgentSnapshotPayload): AgentRunResult {
return {
agentId: agent.id,
status: agent.status === 'running' ? 'running' : 'created',
provider: agent.provider,
cwd: agent.cwd,
title: agent.title,
}
}
export async function runRunCommand(
prompt: string,
options: AgentRunOptions,
_command: Command
): Promise<SingleResult<AgentRunResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate prompt is provided
if (!prompt || prompt.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_PROMPT',
message: 'A prompt is required',
details: 'Usage: paseo agent run [options] <prompt>',
}
throw error
}
// Validate --base is only used with --worktree
if (options.base && !options.worktree) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: '--base can only be used with --worktree',
details: 'Usage: paseo agent run --worktree <name> --base <branch> <prompt>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Resolve working directory
const cwd = options.cwd ?? process.cwd()
// Process images if provided
let images: Array<{ data: string; mimeType: string }> | undefined
if (options.image && options.image.length > 0) {
images = options.image.map((imagePath) => {
const resolvedPath = resolve(imagePath)
try {
const imageData = readFileSync(resolvedPath)
const mimeType = lookup(resolvedPath) || 'application/octet-stream'
// Verify it's an image MIME type
if (!mimeType.startsWith('image/')) {
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`)
}
return {
data: imageData.toString('base64'),
mimeType,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to read image ${imagePath}: ${message}`)
}
})
}
// Build git options if worktree is specified
const git = options.worktree
? {
createWorktree: true,
worktreeSlug: options.worktree,
baseBranch: options.base,
}
: undefined
// Build labels from --label and --ui flags
// --ui is syntactic sugar for --label ui=true
// If explicit --label ui=... is provided, it takes precedence over --ui
const labels: Record<string, string> = {}
if (options.label) {
for (const labelStr of options.label) {
const eqIndex = labelStr.indexOf('=')
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
message: `Invalid label format: ${labelStr}`,
details: 'Labels must be in key=value format',
}
throw error
}
const key = labelStr.slice(0, eqIndex)
const value = labelStr.slice(eqIndex + 1)
labels[key] = value
}
}
// Add ui=true if --ui flag is set and ui label not already set
if (options.ui && !('ui' in labels)) {
labels['ui'] = 'true'
}
// Create the agent
const agent = await client.createAgent({
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
cwd,
title: options.name,
modeId: options.mode,
model: options.model,
initialPrompt: prompt,
images,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
await client.close()
return {
type: 'single',
data: toRunResult(agent),
schema: agentRunSchema,
}
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'AGENT_CREATE_FAILED',
message: `Failed to create agent: ${message}`,
}
throw error
}
}
+214
View File
@@ -0,0 +1,214 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { readFile } from 'node:fs/promises'
import { extname } from 'node:path'
/** Result type for agent send command */
export interface AgentSendResult {
agentId: string
status: 'sent' | 'completed'
message: string
}
/** Schema for agent send output */
export const agentSendSchema: OutputSchema<AgentSendResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 12 },
{ header: 'MESSAGE', field: 'message', width: 40 },
],
}
export interface AgentSendOptions extends CommandOptions {
noWait?: boolean
image?: string[]
}
/**
* Resolve agent ID from prefix or full ID.
* Supports exact match and prefix matching.
*/
function resolveAgentId(agents: AgentSnapshotPayload[], idOrPrefix: string): string | null {
// Exact match first
const exact = agents.find((a) => a.id === idOrPrefix)
if (exact) return exact.id
// Prefix match
const matches = agents.filter((a) => a.id.startsWith(idOrPrefix))
if (matches.length === 1 && matches[0]) return matches[0].id
if (matches.length > 1) {
throw new Error(
`Ambiguous ID prefix '${idOrPrefix}': matches ${matches.length} agents (${matches.map((a) => a.id.slice(0, 7)).join(', ')})`
)
}
return null
}
/**
* Read image files and convert them to base64 data URIs
*/
async function readImageFiles(imagePaths: string[]): Promise<Array<{ data: string; mimeType: string }>> {
const images: Array<{ data: string; mimeType: string }> = []
for (const path of imagePaths) {
try {
const buffer = await readFile(path)
const ext = extname(path).toLowerCase()
// Determine media type from extension
let mimeType = 'image/jpeg'
switch (ext) {
case '.png':
mimeType = 'image/png'
break
case '.jpg':
case '.jpeg':
mimeType = 'image/jpeg'
break
case '.gif':
mimeType = 'image/gif'
break
case '.webp':
mimeType = 'image/webp'
break
default:
// Default to jpeg for unknown types
mimeType = 'image/jpeg'
}
const data = buffer.toString('base64')
images.push({
data,
mimeType,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'IMAGE_READ_ERROR',
message: `Failed to read image file: ${path}`,
details: message,
}
throw error
}
}
return images
}
export async function runSendCommand(
agentIdArg: string,
prompt: string,
options: AgentSendOptions,
_command: Command
): Promise<SingleResult<AgentSendResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent send [options] <id> <prompt>',
}
throw error
}
if (!prompt || prompt.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_PROMPT',
message: 'A prompt is required',
details: 'Usage: paseo agent send [options] <id> <prompt>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID (supports prefix matching)
const agentId = resolveAgentId(agents, agentIdArg)
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Read image files if provided
const images = options.image && options.image.length > 0
? await readImageFiles(options.image)
: undefined
// Send the message
await client.sendAgentMessage(agentId, prompt, { images })
// If --no-wait, return immediately
if (options.noWait) {
await client.close()
return {
type: 'single',
data: {
agentId,
status: 'sent',
message: 'Message sent, not waiting for completion',
},
schema: agentSendSchema,
}
}
// Wait for agent to become idle
await client.waitForAgentIdle(agentId, 600000) // 10 minute timeout
await client.close()
return {
type: 'single',
data: {
agentId,
status: 'completed',
message: 'Agent completed processing the message',
},
schema: agentSendSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'SEND_FAILED',
message: `Failed to send message: ${message}`,
}
throw error
}
}
+131
View File
@@ -0,0 +1,131 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result type for agent stop command */
export interface StopResult {
stoppedCount: number
agentIds: string[]
}
/** Schema for stop command output */
export const stopSchema: OutputSchema<StopResult> = {
// For quiet mode, output the stopped agent IDs (one per line)
idField: (item) => item.agentIds.join('\n'),
columns: [{ header: 'STOPPED', field: 'stoppedCount' }],
}
export interface AgentStopOptions extends CommandOptions {
all?: boolean
cwd?: string
}
export type AgentStopResult = SingleResult<StopResult>
export async function runStopCommand(
id: string | undefined,
options: AgentStopOptions,
_command: Command
): Promise<AgentStopResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments - need either an id, --all, or --cwd
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Agent ID required unless --all or --cwd is specified',
details: 'Usage: paseo agent stop <id> | --all | --cwd <path>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
let agents = client.listAgents()
const stoppedIds: string[] = []
if (options.all) {
// Stop all agents (not archived)
agents = agents.filter((a) => !a.archivedAt)
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd
agents = agents.filter((a) => {
if (a.archivedAt) return false
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = filterCwd.replace(/\/$/, '')
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
} else if (id) {
// Stop specific agent
const resolvedId = resolveAgentId(id, agents)
if (!resolvedId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
}
agents = agents.filter((a) => a.id === resolvedId)
}
// Stop each agent
for (const agent of agents) {
try {
// Cancel if running
if (agent.status === 'running') {
await client.cancelAgent(agent.id)
}
// Delete the agent
await client.deleteAgent(agent.id)
stoppedIds.push(agent.id)
} catch (err) {
// Continue stopping other agents even if one fails
const message = err instanceof Error ? err.message : String(err)
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`)
}
}
await client.close()
return {
type: 'single',
data: {
stoppedCount: stoppedIds.length,
agentIds: stoppedIds,
},
schema: stopSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw if it's already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'STOP_AGENT_FAILED',
message: `Failed to stop agent(s): ${message}`,
}
throw error
}
}
+215
View File
@@ -0,0 +1,215 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result type for agent wait command */
export interface AgentWaitResult {
agentId: string
status: 'idle' | 'timeout' | 'permission'
message: string
}
/** Schema for agent wait output */
export const agentWaitSchema: OutputSchema<AgentWaitResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 12 },
{ header: 'MESSAGE', field: 'message', width: 40 },
],
}
export interface AgentWaitOptions extends CommandOptions {
timeout?: string
host?: string
}
/**
* Parse duration string to milliseconds.
* Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
* If no unit is specified, assumes seconds.
*/
function parseDuration(input: string): number {
const trimmed = input.trim()
// If it's just a number, treat as seconds
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) * 1000
}
// Parse duration with units
let totalMs = 0
const regex = /(\d+)([smh])/g
let match
let hasMatch = false
while ((match = regex.exec(trimmed)) !== null) {
hasMatch = true
const value = parseInt(match[1], 10)
const unit = match[2]
switch (unit) {
case 's':
totalMs += value * 1000
break
case 'm':
totalMs += value * 60 * 1000
break
case 'h':
totalMs += value * 60 * 60 * 1000
break
}
}
if (!hasMatch) {
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`)
}
return totalMs
}
export async function runWaitCommand(
agentIdArg: string,
options: AgentWaitOptions,
_command: Command
): Promise<SingleResult<AgentWaitResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent wait <id>',
}
throw error
}
// Parse timeout (default 10 minutes)
let timeoutMs: number
if (options.timeout) {
try {
timeoutMs = parseDuration(options.timeout)
if (timeoutMs <= 0) {
throw new Error('Timeout must be positive')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_TIMEOUT',
message: 'Invalid timeout value',
details: message,
}
throw error
}
} else {
timeoutMs = 10 * 60 * 1000 // default 10 minutes
}
const timeoutSeconds = Math.floor(timeoutMs / 1000)
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID (supports prefix matching)
const agentId = resolveAgentId(agentIdArg, agents)
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Wait for agent to become idle OR request permission (whichever comes first)
try {
const idlePromise = client.waitForAgentIdle(agentId, timeoutMs).then(() => ({ type: 'idle' as const }))
const permissionPromise = client
.waitForPermission(agentId, timeoutMs)
.then((request) => ({ type: 'permission' as const, request }))
const result = await Promise.race([idlePromise, permissionPromise])
await client.close()
if (result.type === 'permission') {
return {
type: 'single',
data: {
agentId,
status: 'permission',
message: `Agent is waiting for permission: ${result.request.kind}`,
},
schema: agentWaitSchema,
}
}
return {
type: 'single',
data: {
agentId,
status: 'idle',
message: 'Agent is now idle',
},
schema: agentWaitSchema,
}
} catch (waitErr) {
await client.close().catch(() => {})
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr)
// Check if it's a timeout error
if (waitMessage.toLowerCase().includes('timeout')) {
return {
type: 'single',
data: {
agentId,
status: 'timeout',
message: `Timed out waiting for agent after ${timeoutSeconds} seconds`,
},
schema: agentWaitSchema,
}
}
// Other errors
const error: CommandError = {
code: 'WAIT_FAILED',
message: `Failed to wait for agent: ${waitMessage}`,
}
throw error
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'WAIT_FAILED',
message: `Failed to wait for agent: ${message}`,
}
throw error
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Command } from 'commander'
import { startCommand } from './start.js'
import { runStatusCommand } from './status.js'
import { runStopCommand } from './stop.js'
import { runRestartCommand } from './restart.js'
import { withOutput } from '../../output/index.js'
export function createDaemonCommand(): Command {
const daemon = new Command('daemon').description('Manage the Paseo daemon')
daemon.addCommand(startCommand())
daemon
.command('status')
.description('Show daemon status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runStatusCommand)(options, command)
})
daemon
.command('stop')
.description('Stop the daemon')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
daemon
.command('restart')
.description('Restart the daemon')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRestartCommand))
return daemon
}
@@ -0,0 +1,86 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result of restart command */
interface RestartResult {
action: 'restarted' | 'not_running'
host: string
message: string
}
/** Schema for restart result */
const restartResultSchema: OutputSchema<RestartResult> = {
idField: 'action',
columns: [
{
header: 'STATUS',
field: 'action',
color: (value) => (value === 'restarted' ? 'green' : 'red'),
},
{ header: 'HOST', field: 'host' },
{ header: 'MESSAGE', field: 'message' },
],
}
export type RestartCommandResult = SingleResult<RestartResult>
export async function runRestartCommand(
options: CommandOptions,
_command: Command
): Promise<RestartCommandResult> {
const connectOptions = { host: options.host as string | undefined }
const host = getDaemonHost(connectOptions)
let client
try {
client = await connectToDaemon(connectOptions)
} catch {
// Daemon not running - cannot restart
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Daemon is not running (tried to connect to ${host})`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request server restart
await client.restartServer('cli_restart')
await client.close()
return {
type: 'single',
data: {
action: 'restarted',
host,
message: 'Daemon restart requested',
},
schema: restartResultSchema,
}
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
// If connection was closed, the daemon is restarting
if (message.includes('closed') || message.includes('disconnected')) {
return {
type: 'single',
data: {
action: 'restarted',
host,
message: 'Daemon is restarting',
},
schema: restartResultSchema,
}
}
const error: CommandError = {
code: 'RESTART_FAILED',
message: `Failed to restart daemon: ${message}`,
}
throw error
}
}
+100
View File
@@ -0,0 +1,100 @@
import { Command } from 'commander'
import chalk from 'chalk'
import {
createPaseoDaemon,
loadConfig,
resolvePaseoHome,
createRootLogger,
loadPersistedConfig,
} from '@paseo/server'
interface StartOptions {
port?: string
home?: string
foreground?: boolean
noRelay?: boolean
allowedHosts?: string
}
export function startCommand(): Command {
return new Command('start')
.description('Start the Paseo daemon')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--foreground', 'Run in foreground (don\'t daemonize)')
.option('--no-relay', 'Disable relay connection')
.option('--allowed-hosts <hosts>', 'Comma-separated list of allowed MCP hosts (e.g., "localhost:6767,127.0.0.1:6767")')
.action(async (options: StartOptions) => {
await runStart(options)
})
}
async function runStart(options: StartOptions): Promise<void> {
// Set environment variables based on CLI options
if (options.home) {
process.env.PASEO_HOME = options.home
}
if (options.port) {
process.env.PASEO_LISTEN = `127.0.0.1:${options.port}`
}
const paseoHome = resolvePaseoHome()
const persistedConfig = loadPersistedConfig(paseoHome)
const logger = createRootLogger(persistedConfig)
const config = loadConfig(paseoHome)
// Apply CLI overrides
if (options.noRelay) {
config.relayEnabled = false
}
if (options.allowedHosts) {
config.agentMcpAllowedHosts = options.allowedHosts.split(',').map(h => h.trim())
}
// For now, only foreground mode is supported
// TODO: Implement daemonization in a future phase
if (!options.foreground) {
console.log(chalk.yellow('Note: Background daemon mode not yet implemented. Running in foreground.'))
}
const daemon = await createPaseoDaemon(config, logger)
// Handle graceful shutdown
let shuttingDown = false
const handleShutdown = async (signal: string) => {
if (shuttingDown) {
logger.info('Forcing exit...')
process.exit(1)
}
shuttingDown = true
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`)
const forceExit = setTimeout(() => {
logger.warn('Forcing shutdown - HTTP server didn\'t close in time')
process.exit(1)
}, 10000)
try {
await daemon.stop()
clearTimeout(forceExit)
logger.info('Server closed')
process.exit(0)
} catch (err) {
clearTimeout(forceExit)
logger.error({ err }, 'Shutdown failed')
process.exit(1)
}
}
process.on('SIGTERM', () => handleShutdown('SIGTERM'))
process.on('SIGINT', () => handleShutdown('SIGINT'))
try {
await daemon.start()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(`Failed to start daemon: ${message}`))
process.exit(1)
}
}
+112
View File
@@ -0,0 +1,112 @@
import type { Command } from 'commander'
import { resolvePaseoHome } from '@paseo/server'
import { tryConnectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Status data for the daemon */
interface DaemonStatus {
status: 'running' | 'stopped'
host: string
home: string
runningAgents: number
idleAgents: number
}
/** Key-value row for table display */
interface StatusRow {
key: string
value: string
}
/** Schema for key-value display with custom serialization for JSON/YAML */
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
return {
idField: 'key',
columns: [
{ header: 'KEY', field: 'key' },
{
header: 'VALUE',
field: 'value',
color: (_, item) => {
if (item.key === 'Status') {
return item.value === 'running' ? 'green' : 'red'
}
return undefined
},
},
],
// For JSON/YAML, return the structured status object (not key-value rows)
// The serializer receives each item, but we want the whole object
// So we return null for individual items and handle it at the result level
serialize: (_item) => status,
}
}
/** Convert status to key-value rows for table display */
function toStatusRows(status: DaemonStatus): StatusRow[] {
return [
{ key: 'Status', value: status.status },
{ key: 'Host', value: status.host },
{ key: 'Home', value: status.home },
{ key: 'Agents', value: `${status.runningAgents} running, ${status.idleAgents} idle` },
]
}
export type StatusResult = ListResult<StatusRow>
export async function runStatusCommand(
options: CommandOptions,
_command: Command
): Promise<StatusResult> {
const connectOptions = { host: options.host as string | undefined }
const host = getDaemonHost(connectOptions)
const client = await tryConnectToDaemon(connectOptions)
if (!client) {
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Daemon is not running (tried to connect to ${host})`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
const runningAgents = agents.filter((a) => a.status === 'running')
const idleAgents = agents.filter((a) => a.status === 'idle')
// Get paseo home for display
const paseoHome = resolvePaseoHome()
const status: DaemonStatus = {
status: 'running',
host,
home: paseoHome,
runningAgents: runningAgents.length,
idleAgents: idleAgents.length,
}
await client.close()
return {
type: 'list',
data: toStatusRows(status),
schema: createStatusSchema(status),
}
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'STATUS_FAILED',
message: `Failed to get status: ${message}`,
}
throw error
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result of stop command */
interface StopResult {
action: 'stopped' | 'not_running'
host: string
message: string
}
/** Schema for stop result */
const stopResultSchema: OutputSchema<StopResult> = {
idField: 'action',
columns: [
{
header: 'STATUS',
field: 'action',
color: (value) => (value === 'stopped' ? 'green' : 'yellow'),
},
{ header: 'HOST', field: 'host' },
{ header: 'MESSAGE', field: 'message' },
],
}
export type StopCommandResult = SingleResult<StopResult>
export async function runStopCommand(
options: CommandOptions,
_command: Command
): Promise<StopCommandResult> {
const connectOptions = { host: options.host as string | undefined }
const host = getDaemonHost(connectOptions)
let client
try {
client = await connectToDaemon(connectOptions)
} catch {
// Daemon not running - this is a valid outcome
return {
type: 'single',
data: {
action: 'not_running',
host,
message: 'Daemon was not running',
},
schema: stopResultSchema,
}
}
try {
// Request server restart with "shutdown" reason
// This signals the daemon to shut down gracefully
await client.restartServer('cli_shutdown')
// Give the daemon a moment to acknowledge
await new Promise((resolve) => setTimeout(resolve, 500))
await client.close()
return {
type: 'single',
data: {
action: 'stopped',
host,
message: 'Daemon stop requested - shutting down gracefully',
},
schema: stopResultSchema,
}
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
// If connection was closed, the daemon is stopping
if (message.includes('closed') || message.includes('disconnected')) {
return {
type: 'single',
data: {
action: 'stopped',
host,
message: 'Daemon is stopping',
},
schema: stopResultSchema,
}
}
const error: CommandError = {
code: 'STOP_FAILED',
message: `Failed to stop daemon: ${message}`,
}
throw error
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Permission response item for display */
export interface PermissionResponseItem {
requestId: string
agentId: string
agentShortId: string
name: string
result: string
}
/** Schema for permit allow/deny output */
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: 'requestId',
columns: [
{ header: 'REQUEST ID', field: 'requestId', width: 12 },
{ header: 'AGENT', field: 'agentShortId', width: 10 },
{ header: 'TOOL', field: 'name', width: 20 },
{
header: 'RESULT',
field: 'result',
width: 10,
color: (value) => {
if (value === 'allowed') return 'green'
if (value === 'denied') return 'red'
return undefined
},
},
],
}
export type PermitAllowResult = ListResult<PermissionResponseItem>
export interface PermitAllowOptions extends CommandOptions {
all?: boolean
input?: string
host?: string
}
export async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command
): Promise<PermitAllowResult> {
const host = getDaemonHost({ host: options.host })
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined
if (options.input) {
try {
updatedInput = JSON.parse(options.input)
} catch (err) {
const error: CommandError = {
code: 'INVALID_JSON',
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
}
throw error
}
}
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID
const resolvedAgentId = resolveAgentId(agentIdOrPrefix, agents)
if (!resolvedAgentId) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Find the agent
const agent = agents.find((a) => a.id === resolvedAgentId)
if (!agent) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${resolvedAgentId}`,
}
throw error
}
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
if (pendingPermissions.length === 0) {
await client.close()
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[]
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
if (!permission) {
await client.close()
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
}
permissionsToAllow = [permission]
}
// Allow permissions
const results: PermissionResponseItem[] = []
for (const permission of permissionsToAllow) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'allow',
...(updatedInput ? { updatedInput } : {}),
})
results.push({
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: 'allowed',
})
}
await client.close()
return {
type: 'list',
data: results,
schema: permitResponseSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'ALLOW_PERMISSION_FAILED',
message: `Failed to allow permission: ${message}`,
}
throw error
}
}
+146
View File
@@ -0,0 +1,146 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@paseo/server'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, ListResult, CommandError } from '../../output/index.js'
import { permitResponseSchema, type PermissionResponseItem } from './allow.js'
export type PermitDenyResult = ListResult<PermissionResponseItem>
export interface PermitDenyOptions extends CommandOptions {
all?: boolean
message?: string
interrupt?: boolean
host?: string
}
export async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command
): Promise<PermitDenyResult> {
const host = getDaemonHost({ host: options.host })
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Request ID is required unless --all is specified',
details: 'Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Resolve agent ID
const resolvedAgentId = resolveAgentId(agentIdOrPrefix, agents)
if (!resolvedAgentId) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
// Find the agent
const agent = agents.find((a) => a.id === resolvedAgentId)
if (!agent) {
await client.close()
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${resolvedAgentId}`,
}
throw error
}
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
if (pendingPermissions.length === 0) {
await client.close()
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[]
if (options.all) {
permissionsToDeny = pendingPermissions
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
if (!permission) {
await client.close()
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
}
permissionsToDeny = [permission]
}
// Deny permissions
const results: PermissionResponseItem[] = []
for (const permission of permissionsToDeny) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'deny',
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
})
results.push({
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: 'denied',
})
}
await client.close()
return {
type: 'list',
data: results,
schema: permitResponseSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DENY_PERMISSION_FAILED',
message: `Failed to deny permission: ${message}`,
}
throw error
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runAllowCommand } from './allow.js'
import { runDenyCommand } from './deny.js'
import { withOutput } from '../../output/index.js'
export function createPermitCommand(): Command {
const permit = new Command('permit').description('Manage permission requests')
permit
.command('ls')
.description('List all pending permissions')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
permit
.command('allow')
.description('Allow a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runAllowCommand))
permit
.command('deny')
.description('Deny a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Deny all pending permissions for this agent')
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runDenyCommand))
return permit
}
+93
View File
@@ -0,0 +1,93 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest, AgentSnapshotPayload } from '@paseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Permission list item for display */
export interface PermissionListItem {
id: string
agentId: string
agentShortId: string
name: string
description: string
}
/** Schema for permit ls output */
export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: 'id',
columns: [
{ header: 'AGENT', field: 'agentShortId', width: 12 },
{ header: 'REQ_ID', field: 'id', width: 12 },
{ header: 'TOOL', field: 'name', width: 20 },
{ header: 'DESCRIPTION', field: 'description', width: 50 },
],
}
/** Transform agent snapshot + permission to list item */
function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequest): PermissionListItem {
return {
id: permission.id.slice(0, 8),
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
name: permission.name,
description: permission.description ?? '-',
}
}
export type PermitLsResult = ListResult<PermissionListItem>
export interface PermitLsOptions extends CommandOptions {
host?: string
}
export async function runLsCommand(options: PermitLsOptions, _command: Command): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host })
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
await client.close()
// Collect all pending permissions from all agents
const items: PermissionListItem[] = []
for (const agent of agents) {
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
for (const permission of agent.pendingPermissions) {
items.push(toListItem(agent, permission))
}
}
}
return {
type: 'list',
data: items,
schema: permitLsSchema,
}
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'LIST_PERMISSIONS_FAILED',
message: `Failed to list permissions: ${message}`,
}
throw error
}
}
@@ -0,0 +1,35 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runModelsCommand } from './models.js'
import { withOutput } from '../../output/index.js'
export function createProviderCommand(): Command {
const provider = new Command('provider').description('Manage agent providers')
provider
.command('ls')
.description('List available providers and status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
provider
.command('models')
.description('List models for a provider')
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((provider, options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runModelsCommand)(provider, options, command)
})
return provider
}
+70
View File
@@ -0,0 +1,70 @@
import type { Command } from 'commander'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
/** Provider list item for display */
export interface ProviderListItem {
provider: string
status: string
defaultMode: string
modes: string
}
/** Static provider data - providers are built-in and don't require daemon */
const PROVIDERS: ProviderListItem[] = [
{
provider: 'claude',
status: 'available',
defaultMode: 'default',
modes: 'plan, default, bypass',
},
{
provider: 'codex',
status: 'available',
defaultMode: 'auto',
modes: 'read-only, auto, full-access',
},
{
provider: 'opencode',
status: 'available',
defaultMode: 'default',
modes: 'plan, default, bypass',
},
]
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: 'provider',
columns: [
{ header: 'PROVIDER', field: 'provider', width: 12 },
{
header: 'STATUS',
field: 'status',
width: 12,
color: (value) => {
if (value === 'available') return 'green'
if (value === 'unavailable') return 'red'
return undefined
},
},
{ header: 'DEFAULT MODE', field: 'defaultMode', width: 14 },
{ header: 'MODES', field: 'modes', width: 30 },
],
}
export type ProviderLsResult = ListResult<ProviderListItem>
export interface ProviderLsOptions extends CommandOptions {
host?: string
}
export async function runLsCommand(
_options: ProviderLsOptions,
_command: Command
): Promise<ProviderLsResult> {
// Provider data is static - no daemon connection needed
return {
type: 'list',
data: PROVIDERS,
schema: providerLsSchema,
}
}
@@ -0,0 +1,69 @@
import type { Command } from 'commander'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Model list item for display */
export interface ModelListItem {
model: string
id: string
}
/** Static model data by provider */
const MODELS_BY_PROVIDER: Record<string, ModelListItem[]> = {
claude: [
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
],
codex: [
{ model: 'o3-mini', id: 'o3-mini' },
{ model: 'o4-mini', id: 'o4-mini' },
],
opencode: [
// opencode uses claude or codex under the hood
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
{ model: 'o3-mini', id: 'o3-mini' },
{ model: 'o4-mini', id: 'o4-mini' },
],
}
/** Schema for provider models output */
export const providerModelsSchema: OutputSchema<ModelListItem> = {
idField: 'id',
columns: [
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'ID', field: 'id', width: 30 },
],
}
export type ProviderModelsResult = ListResult<ModelListItem>
export interface ProviderModelsOptions extends CommandOptions {
host?: string
}
export async function runModelsCommand(
provider: string,
_options: ProviderModelsOptions,
_command: Command
): Promise<ProviderModelsResult> {
const normalizedProvider = provider.toLowerCase()
const models = MODELS_BY_PROVIDER[normalizedProvider]
if (!models) {
const validProviders = Object.keys(MODELS_BY_PROVIDER).join(', ')
const error: CommandError = {
code: 'UNKNOWN_PROVIDER',
message: `Unknown provider: ${provider}`,
details: `Valid providers: ${validProviders}`,
}
throw error
}
return {
type: 'list',
data: models,
schema: providerModelsSchema,
}
}
@@ -0,0 +1,129 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result type for worktree archive command */
export interface WorktreeArchiveResult {
name: string
status: 'archived'
removedAgents: string[]
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
idField: 'name',
columns: [
{ header: 'NAME', field: 'name' },
{ header: 'STATUS', field: 'status' },
{
header: 'REMOVED AGENTS',
field: (item) => item.removedAgents.length > 0 ? item.removedAgents.join(', ') : '-',
},
],
}
export interface WorktreeArchiveOptions extends CommandOptions {
host?: string
}
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>
export async function runArchiveCommand(
nameArg: string,
options: WorktreeArchiveOptions,
_command: Command
): Promise<WorktreeArchiveCommandResult> {
const host = getDaemonHost({ host: options.host })
// Validate arguments
if (!nameArg || nameArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_WORKTREE_NAME',
message: 'Worktree name is required',
details: 'Usage: paseo worktree archive <name>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Get the list of worktrees first to resolve the name
const listResponse = await client.getPaseoWorktreeList({})
if (listResponse.error) {
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
message: `Failed to list worktrees: ${listResponse.error.message}`,
}
throw error
}
// Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => {
const name = wt.worktreePath.split('/').pop()
return name === nameArg || wt.branchName === nameArg
})
if (!worktree) {
const error: CommandError = {
code: 'WORKTREE_NOT_FOUND',
message: `Worktree not found: ${nameArg}`,
details: 'Use "paseo worktree ls" to list available worktrees',
}
throw error
}
// Archive the worktree
const response = await client.archivePaseoWorktree({
worktreePath: worktree.worktreePath,
})
await client.close()
if (response.error) {
const error: CommandError = {
code: 'WORKTREE_ARCHIVE_FAILED',
message: `Failed to archive worktree: ${response.error.message}`,
}
throw error
}
const worktreeName = worktree.worktreePath.split('/').pop() ?? nameArg
return {
type: 'single',
data: {
name: worktreeName,
status: 'archived',
removedAgents: response.removedAgents ?? [],
},
schema: archiveSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'WORKTREE_ARCHIVE_FAILED',
message: `Failed to archive worktree: ${message}`,
}
throw error
}
}
@@ -0,0 +1,29 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runArchiveCommand } from './archive.js'
import { withOutput } from '../../output/index.js'
export function createWorktreeCommand(): Command {
const worktree = new Command('worktree').description('Manage Paseo-managed git worktrees')
worktree
.command('ls')
.description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => {
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
worktree
.command('archive')
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))
return worktree
}
+125
View File
@@ -0,0 +1,125 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
/** Worktree list item for display */
export interface WorktreeListItem {
name: string
branch: string
cwd: string
agent: string
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
}
return path
}
/** Extract worktree name from path */
function extractWorktreeName(path: string): string {
// ~/.paseo/worktrees/<repo>/<name> -> name
const parts = path.split('/')
return parts[parts.length - 1] ?? path
}
/** Schema for worktree ls output */
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
idField: 'name',
columns: [
{ header: 'NAME', field: 'name', width: 20 },
{ header: 'BRANCH', field: 'branch', width: 25 },
{ header: 'CWD', field: 'cwd', width: 45 },
{ header: 'AGENT', field: 'agent', width: 10 },
],
}
export type WorktreeLsResult = ListResult<WorktreeListItem>
export interface WorktreeLsOptions extends CommandOptions {
host?: string
}
export async function runLsCommand(
options: WorktreeLsOptions,
_command: Command
): Promise<WorktreeLsResult> {
const host = getDaemonHost({ host: options.host })
let client
try {
client = await connectToDaemon({ host: options.host })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise((resolve) => setTimeout(resolve, 500))
const agents = client.listAgents()
// Get worktree list from daemon
const response = await client.getPaseoWorktreeList({})
await client.close()
if (response.error) {
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
message: `Failed to list worktrees: ${response.error.message}`,
}
throw error
}
// Build a map of worktree paths to agent IDs
const worktreeAgentMap = new Map<string, string>()
for (const agent of agents) {
// Check if agent cwd is under ~/.paseo/worktrees/
const paseoHome = process.env.PASEO_HOME ?? (process.env.HOME + '/.paseo')
const worktreesDir = paseoHome + '/worktrees/'
if (agent.cwd.startsWith(worktreesDir)) {
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7))
}
}
const items: WorktreeListItem[] = response.worktrees.map((wt) => ({
name: extractWorktreeName(wt.worktreePath),
branch: wt.branchName ?? '-',
cwd: shortenPath(wt.worktreePath),
agent: worktreeAgentMap.get(wt.worktreePath) ?? '-',
}))
return {
type: 'list',
data: items,
schema: worktreeLsSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
message: `Failed to list worktrees: ${message}`,
}
throw error
}
}
+4
View File
@@ -0,0 +1,4 @@
import { createCli } from './cli.js'
const program = createCli()
program.parse()
+62
View File
@@ -0,0 +1,62 @@
/**
* Output abstraction layer for the Paseo CLI.
*
* This module provides structured output rendering with support for multiple formats:
* - table: Human-readable aligned tables (default)
* - json: Machine-readable JSON
* - yaml: Machine-readable YAML
* - quiet: Minimal output (IDs only)
*
* @example
* ```typescript
* import { withOutput, render, type ListResult, type OutputSchema } from './output/index.js'
*
* // Define your data type
* interface Agent { id: string; title: string; status: string }
*
* // Define how to render it
* const schema: OutputSchema<Agent> = {
* idField: 'id',
* columns: [
* { header: 'ID', field: 'id' },
* { header: 'TITLE', field: 'title' },
* { header: 'STATUS', field: 'status', color: (v) => v === 'running' ? 'green' : undefined },
* ],
* }
*
* // Return structured data from commands
* const result: ListResult<Agent> = {
* type: 'list',
* data: agents,
* schema,
* }
*
* // Render with options
* const output = render(result, { format: 'json' })
* ```
*/
// Types
export type {
OutputFormat,
OutputOptions,
ColumnDef,
OutputSchema,
CommandResult,
SingleResult,
ListResult,
AnyCommandResult,
CommandError,
} from './types.js'
// Renderers
export { renderTable, renderTableHeader, renderTableRow } from './table.js'
export { renderJson, renderJsonLine } from './json.js'
export { renderYaml, renderYamlDoc } from './yaml.js'
export { renderQuiet } from './quiet.js'
// Main render function
export { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
// Command wrapper
export { withOutput, createOutputOptions, type CommandOptions } from './with-output.js'
+44
View File
@@ -0,0 +1,44 @@
/**
* JSON renderer for CLI output.
*
* Renders structured data as formatted JSON for machine consumption.
*/
import type { AnyCommandResult, OutputOptions } from './types.js'
/** Render command result as JSON */
export function renderJson<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
const { schema } = result
// Apply custom serializer if provided
if (schema.serialize) {
if (result.type === 'list') {
// If all items serialize to the same object, return just one
// This handles the case where a list of key-value rows should serialize
// to a single structured object
const serialized = result.data.map((item) => schema.serialize!(item))
if (serialized.length > 0) {
const first = JSON.stringify(serialized[0])
const allSame = serialized.every((s) => JSON.stringify(s) === first)
if (allSame) {
return JSON.stringify(serialized[0], null, 2)
}
}
return JSON.stringify(serialized, null, 2)
} else {
const serialized = schema.serialize(result.data)
return JSON.stringify(serialized, null, 2)
}
}
return JSON.stringify(result.data, null, 2)
}
/** Render a single item as JSON line (for NDJSON streaming) */
export function renderJsonLine<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return JSON.stringify(output)
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Quiet renderer for CLI output.
*
* Outputs only ID fields, one per line. Useful for scripting and pipelines.
*/
import type { AnyCommandResult, OutputOptions } from './types.js'
/** Extract ID from item using schema definition */
function getId<T>(item: T, idField: keyof T | ((item: T) => string)): string {
if (typeof idField === 'function') {
return idField(item)
}
return String(item[idField])
}
/** Render command result in quiet mode (IDs only) */
export function renderQuiet<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
if (result.type === 'single') {
return getId(result.data, result.schema.idField)
} else {
return result.data.map((item) => getId(item, result.schema.idField)).join('\n')
}
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Main render dispatcher for CLI output.
*
* Selects the appropriate renderer based on output options.
*/
import chalk from 'chalk'
import YAML from 'yaml'
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
import { renderTable } from './table.js'
import { renderJson } from './json.js'
import { renderYaml } from './yaml.js'
import { renderQuiet } from './quiet.js'
/** Default output options */
export const defaultOutputOptions: OutputOptions = {
format: 'table',
quiet: false,
noHeaders: false,
noColor: false,
}
/** Render command result to string based on output options */
export function render<T>(
result: AnyCommandResult<T>,
options: Partial<OutputOptions> = {}
): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
// Quiet mode takes precedence
if (opts.quiet) {
return renderQuiet(result, opts)
}
// Dispatch to format-specific renderer
switch (opts.format) {
case 'json':
return renderJson(result, opts)
case 'yaml':
return renderYaml(result, opts)
case 'table':
default:
return renderTable(result, opts)
}
}
/** Convert an unknown error to a CommandError */
export function toCommandError(error: unknown): CommandError {
if (isCommandError(error)) {
return error
}
if (error instanceof Error) {
return {
code: 'UNKNOWN_ERROR',
message: error.message,
details: error.stack,
}
}
return {
code: 'UNKNOWN_ERROR',
message: String(error),
}
}
/** Type guard for CommandError */
function isCommandError(error: unknown): error is CommandError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error &&
typeof (error as CommandError).code === 'string' &&
typeof (error as CommandError).message === 'string'
)
}
/** Render an error to string based on output options */
export function renderError(
error: CommandError,
options: Partial<OutputOptions> = {}
): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
if (opts.format === 'json') {
return JSON.stringify({ error }, null, 2)
}
if (opts.format === 'yaml') {
return YAML.stringify({ error })
}
// Table/default format: human-readable error
const prefix = opts.noColor ? 'Error: ' : chalk.red('Error: ')
const message = error.message
if (error.details && typeof error.details === 'string') {
return `${prefix}${message}\n${error.details}`
}
return `${prefix}${message}`
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Table renderer for CLI output.
*
* Renders structured data as aligned ASCII tables with optional color support.
*/
import chalk, { type ChalkInstance } from 'chalk'
import type {
AnyCommandResult,
ColumnDef,
OutputOptions,
OutputSchema,
} from './types.js'
// ANSI escape code regex for stripping colors when measuring width
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g
/** Strip ANSI escape codes from a string */
function stripAnsi(str: string): string {
return str.replace(ANSI_REGEX, '')
}
/** Get visible string length (excluding ANSI codes) */
function visibleLength(str: string): number {
return stripAnsi(str).length
}
/** Pad a cell to the specified width with alignment */
function padCell(
cell: string,
width: number,
align: 'left' | 'right' | 'center'
): string {
const visible = visibleLength(cell)
const padding = Math.max(0, width - visible)
switch (align) {
case 'right':
return ' '.repeat(padding) + cell
case 'center': {
const left = Math.floor(padding / 2)
const right = padding - left
return ' '.repeat(left) + cell + ' '.repeat(right)
}
case 'left':
default:
return cell + ' '.repeat(padding)
}
}
/** Apply a chalk color to a string */
function applyColor(str: string, colorName: string): string {
// Map color names to chalk methods
const colorMap: Record<string, ChalkInstance> = {
red: chalk.red,
green: chalk.green,
blue: chalk.blue,
yellow: chalk.yellow,
cyan: chalk.cyan,
magenta: chalk.magenta,
white: chalk.white,
gray: chalk.gray,
grey: chalk.grey,
dim: chalk.dim,
bold: chalk.bold,
}
const colorFn = colorMap[colorName]
return colorFn ? colorFn(str) : str
}
/** Extract value from item using field definition */
function getValue<T>(item: T, field: keyof T | ((item: T) => unknown)): unknown {
return typeof field === 'function' ? field(item) : item[field]
}
/** Render a single table row */
function renderRow<T>(
item: T,
columns: ColumnDef<T>[],
widths: number[],
options: OutputOptions
): string {
return columns
.map((col, colIndex) => {
const value = getValue(item, col.field)
let cell = String(value ?? '')
const width = widths[colIndex]
// Apply color if enabled
if (col.color && !options.noColor) {
const colorName = col.color(value, item)
if (colorName) {
cell = applyColor(cell, colorName)
}
}
return padCell(cell, width ?? 0, col.align ?? 'left')
})
.join(' ')
}
/** Render header row */
function renderHeader<T>(
columns: ColumnDef<T>[],
widths: number[],
options: OutputOptions
): string {
const headerRow = columns
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? 'left'))
.join(' ')
return options.noColor ? headerRow : chalk.bold(headerRow)
}
/** Calculate column widths based on content and hints */
function calculateWidths<T>(
data: T[],
columns: ColumnDef<T>[],
includeHeaders: boolean
): number[] {
return columns.map((col) => {
// Start with header width if including headers
let maxWidth = includeHeaders ? col.header.length : 0
// Check all data values
for (const item of data) {
const value = getValue(item, col.field)
const str = String(value ?? '')
maxWidth = Math.max(maxWidth, visibleLength(str))
}
// Apply width hint if specified (minimum width)
if (col.width) {
maxWidth = Math.max(maxWidth, col.width)
}
return maxWidth
})
}
/** Render a list result as a table */
export function renderTable<T>(
result: AnyCommandResult<T>,
options: OutputOptions
): string {
const { schema } = result
const data = result.type === 'list' ? result.data : [result.data]
if (data.length === 0) {
return ''
}
const columns = schema.columns as ColumnDef<T>[]
const includeHeaders = !options.noHeaders
const widths = calculateWidths(data, columns, includeHeaders)
const lines: string[] = []
// Add header row
if (includeHeaders) {
lines.push(renderHeader(columns, widths, options))
}
// Add data rows
for (const item of data) {
lines.push(renderRow(item, columns, widths, options))
}
return lines.join('\n')
}
/** Render just a table header (for streaming) */
export function renderTableHeader<T>(
schema: OutputSchema<T>,
options: OutputOptions,
widths?: number[]
): string {
const columns = schema.columns
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
return renderHeader(columns, actualWidths, options)
}
/** Render just a table row (for streaming) */
export function renderTableRow<T>(
item: T,
schema: OutputSchema<T>,
options: OutputOptions,
widths?: number[]
): string {
const columns = schema.columns
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
return renderRow(item, columns, actualWidths, options)
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Output format types for the Paseo CLI.
*
* This module defines the structured data types used by the output abstraction layer.
* Commands return CommandResult<T> which contains both data and rendering metadata.
*/
/** Supported output formats */
export type OutputFormat = 'table' | 'json' | 'yaml'
/** Options controlling output rendering */
export interface OutputOptions {
/** Output format (table, json, yaml) */
format: OutputFormat
/** Minimal output - IDs only */
quiet: boolean
/** Omit table headers */
noHeaders: boolean
/** Disable color output */
noColor: boolean
}
/** Column definition for table output */
export interface ColumnDef<T> {
/** Header text for the column */
header: string
/** Field key or accessor function */
field: keyof T | ((item: T) => unknown)
/** Optional width hint (characters) */
width?: number
/** Optional alignment */
align?: 'left' | 'right' | 'center'
/** Optional color function - returns chalk color name */
color?: (value: unknown, item: T) => string | undefined
}
/** Schema describing how to render command output */
export interface OutputSchema<T> {
/** Field to use for quiet mode (--quiet outputs just this) */
idField: keyof T | ((item: T) => string)
/** Column definitions for table output */
columns: ColumnDef<T>[]
/** Optional: transform data before JSON/YAML output */
serialize?: (data: T) => unknown
}
/** Result type for commands returning a single item */
export interface SingleResult<T> {
type: 'single'
/** The structured data to render */
data: T
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
}
/** Result type for commands returning a list */
export interface ListResult<T> {
type: 'list'
/** The structured data to render */
data: T[]
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
}
/** Union type for all command results */
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
/** Base interface for command results (deprecated, use SingleResult or ListResult) */
export type CommandResult<T> = SingleResult<T> | ListResult<T>
/** Structured error for command failures */
export interface CommandError {
/** Machine-readable error code */
code: string
/** Human-readable message */
message: string
/** Additional context */
details?: unknown
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Command wrapper for automatic output rendering.
*
* Wraps command handlers to automatically render results and handle errors.
*/
import type { Command } from 'commander'
import type { AnyCommandResult, OutputOptions } from './types.js'
import { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
/** Options that include output settings from global options */
export interface CommandOptions extends Partial<OutputOptions> {
[key: string]: unknown
}
/** Extract output options from command options */
function extractOutputOptions(options: CommandOptions): OutputOptions {
return {
format: (options.format as OutputOptions['format']) ?? defaultOutputOptions.format,
quiet: options.quiet ?? defaultOutputOptions.quiet,
noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false
noColor: options.color === false, // Commander uses --no-color -> color: false
}
}
/**
* Wrap a command handler to automatically render output.
*
* The wrapped handler should return a CommandResult. The wrapper will:
* 1. Call the handler
* 2. Render the result using the appropriate format
* 3. Write to stdout
* 4. Handle errors by rendering to stderr and exiting with code 1
*
* @example
* ```typescript
* program
* .command('list')
* .action(withOutput(async (options) => {
* const data = await fetchData()
* return { type: 'list', data, schema }
* }))
* ```
*/
export function withOutput<T, Args extends unknown[]>(
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>
): (...args: [...Args, CommandOptions, Command]) => Promise<void> {
return async (...args) => {
// Last two args are options and command
const command = args[args.length - 1] as Command
// Use optsWithGlobals() to get both local and global options
const options = command.optsWithGlobals() as CommandOptions
const outputOptions = extractOutputOptions(options)
try {
const result = await handler(...args)
const output = render(result, outputOptions)
if (output) {
process.stdout.write(output + '\n')
}
} catch (error) {
const commandError = toCommandError(error)
const errorOutput = renderError(commandError, outputOptions)
process.stderr.write(errorOutput + '\n')
process.exit(1)
}
}
}
/**
* Helper to create output options from partial input.
* Useful for testing or manual rendering.
*/
export function createOutputOptions(
partial: Partial<OutputOptions> = {}
): OutputOptions {
return { ...defaultOutputOptions, ...partial }
}
+45
View File
@@ -0,0 +1,45 @@
/**
* YAML renderer for CLI output.
*
* Renders structured data as YAML for machine consumption and human readability.
*/
import YAML from 'yaml'
import type { AnyCommandResult, OutputOptions } from './types.js'
/** Render command result as YAML */
export function renderYaml<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
const { schema } = result
// Apply custom serializer if provided
if (schema.serialize) {
if (result.type === 'list') {
// If all items serialize to the same object, return just one
// This handles the case where a list of key-value rows should serialize
// to a single structured object
const serialized = result.data.map((item) => schema.serialize!(item))
if (serialized.length > 0) {
const first = JSON.stringify(serialized[0])
const allSame = serialized.every((s) => JSON.stringify(s) === first)
if (allSame) {
return YAML.stringify(serialized[0])
}
}
return YAML.stringify(serialized)
} else {
const serialized = schema.serialize(result.data)
return YAML.stringify(serialized)
}
}
return YAML.stringify(result.data)
}
/** Render a single item as YAML document (for streaming) */
export function renderYamlDoc<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return YAML.stringify(output)
}
+130
View File
@@ -0,0 +1,130 @@
import { DaemonClientV2 } from '@paseo/server'
import WebSocket from 'ws'
export interface ConnectOptions {
host?: string
timeout?: number
}
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (url: string, options?: { headers?: Record<string, string> }) => {
return new WebSocket(url, { headers: options?.headers }) as unknown as {
readyState: number
send: (data: string) => void
close: (code?: number, reason?: string) => void
on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void
}
}
}
/**
* Create and connect a daemon client
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2> {
const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const url = `ws://${host}/ws`
const client = new DaemonClientV2({
url,
webSocketFactory: createNodeWebSocketFactory(),
reconnect: { enabled: false },
})
// Connect with timeout
const connectPromise = client.connect()
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
try {
await Promise.race([connectPromise, timeoutPromise])
return client
} catch (err) {
await client.close().catch(() => {})
throw err
}
}
/**
* Try to connect to the daemon, returns null if connection fails
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2 | null> {
try {
return await connectToDaemon(options)
} catch {
return null
}
}
/** Minimal agent type for ID resolution */
interface AgentLike {
id: string
title?: string | null
}
/**
* Resolve an agent ID from a partial ID or name.
* Supports:
* - Full ID match
* - Prefix match (first N characters)
* - Title/name match (case-insensitive)
*
* Returns the full agent ID if found, null otherwise.
*/
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
if (!idOrName || agents.length === 0) {
return null
}
const query = idOrName.toLowerCase()
// Try exact ID match first
const exactMatch = agents.find((a) => a.id === idOrName)
if (exactMatch) {
return exactMatch.id
}
// Try ID prefix match
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query))
if (prefixMatches.length === 1 && prefixMatches[0]) {
return prefixMatches[0].id
}
// Try title/name match (case-insensitive)
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query)
if (titleMatches.length === 1 && titleMatches[0]) {
return titleMatches[0].id
}
// Try partial title match
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query))
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
return partialTitleMatches[0].id
}
// If we have multiple prefix matches and no unique title match, return first prefix match
const firstPrefixMatch = prefixMatches[0]
if (firstPrefixMatch) {
return firstPrefixMatch.id
}
return null
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env npx zx
/**
* Phase 1: Foundation Tests
*
* Tests basic CLI functionality that doesn't require a daemon:
* - paseo --version outputs version
* - paseo --help shows commands
*/
import { $ } from 'zx'
$.verbose = false
console.log('📋 Phase 1: Foundation Tests\n')
// Test 1.1: --version outputs version
console.log(' Testing paseo --version...')
const versionResult = await $`paseo --version`.nothrow()
if (versionResult.exitCode !== 0) {
console.error(' ❌ paseo --version failed with exit code', versionResult.exitCode)
console.error(' stderr:', versionResult.stderr)
process.exit(1)
}
const versionOutput = versionResult.stdout.trim()
if (!versionOutput.match(/\d+\.\d+\.\d+/)) {
console.error(' ❌ paseo --version output does not contain version number')
console.error(' output:', versionOutput)
process.exit(1)
}
console.log(' ✅ paseo --version outputs:', versionOutput)
// Test 1.2: --help shows commands
console.log(' Testing paseo --help...')
const helpResult = await $`paseo --help`.nothrow()
if (helpResult.exitCode !== 0) {
console.error(' ❌ paseo --help failed with exit code', helpResult.exitCode)
console.error(' stderr:', helpResult.stderr)
process.exit(1)
}
const helpOutput = helpResult.stdout
// Check for expected sections in help output
const expectedTerms = ['agent', 'daemon', 'Usage', 'Options', 'Commands']
const missingTerms = expectedTerms.filter(term => !helpOutput.includes(term))
if (missingTerms.length > 0) {
console.error(' ❌ paseo --help missing expected terms:', missingTerms.join(', '))
console.error(' output:', helpOutput)
process.exit(1)
}
console.log(' ✅ paseo --help shows commands')
console.log('\n✅ Phase 1: Foundation Tests PASSED')
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env npx tsx
/**
* Tests for the output abstraction layer.
*
* Verifies that renderers correctly format structured data.
*/
import assert from 'node:assert'
import {
render,
renderTable,
renderJson,
renderYaml,
renderQuiet,
renderError,
toCommandError,
createOutputOptions,
type ListResult,
type SingleResult,
type OutputSchema,
type CommandError,
} from '../src/output/index.js'
// Test data types
interface Agent {
id: string
title: string
status: 'running' | 'idle' | 'error'
provider: string
}
// Schema for agents
const agentSchema: OutputSchema<Agent> = {
idField: 'id',
columns: [
{ header: 'ID', field: 'id', width: 8 },
{ header: 'TITLE', field: 'title', width: 20 },
{
header: 'STATUS',
field: 'status',
color: (value) => {
switch (value) {
case 'running':
return 'green'
case 'idle':
return 'dim'
case 'error':
return 'red'
default:
return undefined
}
},
},
{ header: 'PROVIDER', field: 'provider' },
],
}
// Test data
const testAgents: Agent[] = [
{ id: 'abc123', title: 'Feature Implementation', status: 'running', provider: 'claude' },
{ id: 'def456', title: 'Bug Fix', status: 'idle', provider: 'codex' },
{ id: 'ghi789', title: 'Failed Task', status: 'error', provider: 'claude' },
]
const listResult: ListResult<Agent> = {
type: 'list',
data: testAgents,
schema: agentSchema,
}
const singleResult: SingleResult<Agent> = {
type: 'single',
data: testAgents[0],
schema: agentSchema,
}
// Test utilities
let passed = 0
let failed = 0
function test(name: string, fn: () => void): void {
try {
fn()
console.log(`✓ ${name}`)
passed++
} catch (error) {
console.error(`✗ ${name}`)
console.error(` ${error instanceof Error ? error.message : error}`)
failed++
}
}
// Table renderer tests
console.log('\n=== Table Renderer ===\n')
test('renderTable formats list data with headers', () => {
const output = renderTable(listResult, createOutputOptions({ noColor: true }))
assert.ok(output.includes('ID'), 'Should include ID header')
assert.ok(output.includes('TITLE'), 'Should include TITLE header')
assert.ok(output.includes('STATUS'), 'Should include STATUS header')
assert.ok(output.includes('abc123'), 'Should include first agent ID')
assert.ok(output.includes('Feature Implementation'), 'Should include first agent title')
})
test('renderTable omits headers when noHeaders is true', () => {
const output = renderTable(listResult, createOutputOptions({ noHeaders: true, noColor: true }))
assert.ok(!output.includes('ID '), 'Should not include header row')
assert.ok(output.includes('abc123'), 'Should still include data')
})
test('renderTable handles empty data', () => {
const emptyResult: ListResult<Agent> = {
type: 'list',
data: [],
schema: agentSchema,
}
const output = renderTable(emptyResult, createOutputOptions())
assert.strictEqual(output, '', 'Should return empty string for empty data')
})
test('renderTable handles single result', () => {
const output = renderTable(singleResult, createOutputOptions({ noColor: true }))
assert.ok(output.includes('abc123'), 'Should include agent ID')
assert.ok(output.includes('Feature Implementation'), 'Should include agent title')
})
// JSON renderer tests
console.log('\n=== JSON Renderer ===\n')
test('renderJson outputs valid JSON for list', () => {
const output = renderJson(listResult, createOutputOptions())
const parsed = JSON.parse(output)
assert.ok(Array.isArray(parsed), 'Should be an array')
assert.strictEqual(parsed.length, 3, 'Should have 3 items')
assert.strictEqual(parsed[0].id, 'abc123', 'Should include correct data')
})
test('renderJson outputs valid JSON for single item', () => {
const output = renderJson(singleResult, createOutputOptions())
const parsed = JSON.parse(output)
assert.strictEqual(parsed.id, 'abc123', 'Should include correct data')
assert.strictEqual(parsed.title, 'Feature Implementation', 'Should include correct title')
})
test('renderJson uses custom serializer when provided', () => {
const customSchema: OutputSchema<Agent> = {
...agentSchema,
serialize: (agent) => ({ agentId: agent.id, name: agent.title }),
}
const customResult: SingleResult<Agent> = {
type: 'single',
data: testAgents[0],
schema: customSchema,
}
const output = renderJson(customResult, createOutputOptions())
const parsed = JSON.parse(output)
assert.ok('agentId' in parsed, 'Should use custom serialization')
assert.ok('name' in parsed, 'Should use custom field names')
})
// YAML renderer tests
console.log('\n=== YAML Renderer ===\n')
test('renderYaml outputs valid YAML for list', () => {
const output = renderYaml(listResult, createOutputOptions())
assert.ok(output.includes('- id: abc123'), 'Should format as YAML list')
assert.ok(output.includes('title: Feature Implementation'), 'Should include title')
})
test('renderYaml outputs valid YAML for single item', () => {
const output = renderYaml(singleResult, createOutputOptions())
assert.ok(output.includes('id: abc123'), 'Should include ID')
assert.ok(!output.startsWith('-'), 'Should not be a list')
})
// Quiet renderer tests
console.log('\n=== Quiet Renderer ===\n')
test('renderQuiet outputs only IDs for list', () => {
const output = renderQuiet(listResult, createOutputOptions())
const lines = output.split('\n')
assert.strictEqual(lines.length, 3, 'Should have 3 lines')
assert.strictEqual(lines[0], 'abc123', 'First line should be first ID')
assert.strictEqual(lines[1], 'def456', 'Second line should be second ID')
})
test('renderQuiet outputs only ID for single item', () => {
const output = renderQuiet(singleResult, createOutputOptions())
assert.strictEqual(output, 'abc123', 'Should output only the ID')
})
test('renderQuiet supports function idField', () => {
const customSchema: OutputSchema<Agent> = {
...agentSchema,
idField: (agent) => `${agent.provider}/${agent.id}`,
}
const customResult: SingleResult<Agent> = {
type: 'single',
data: testAgents[0],
schema: customSchema,
}
const output = renderQuiet(customResult, createOutputOptions())
assert.strictEqual(output, 'claude/abc123', 'Should use function to extract ID')
})
// Main render dispatcher tests
console.log('\n=== Render Dispatcher ===\n')
test('render uses table format by default', () => {
const output = render(listResult, { noColor: true })
assert.ok(output.includes('ID'), 'Should use table format with headers')
})
test('render uses json format when specified', () => {
const output = render(listResult, { format: 'json' })
const parsed = JSON.parse(output)
assert.ok(Array.isArray(parsed), 'Should be valid JSON array')
})
test('render uses yaml format when specified', () => {
const output = render(listResult, { format: 'yaml' })
assert.ok(output.includes('- id:'), 'Should be valid YAML')
})
test('render uses quiet mode when quiet is true', () => {
const output = render(listResult, { quiet: true })
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Should output only IDs')
})
test('quiet mode takes precedence over format', () => {
const output = render(listResult, { format: 'json', quiet: true })
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Quiet should override format')
})
// Error rendering tests
console.log('\n=== Error Rendering ===\n')
test('renderError formats error for table format', () => {
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
const output = renderError(error, { noColor: true })
assert.ok(output.includes('Error:'), 'Should include Error prefix')
assert.ok(output.includes('Agent not found'), 'Should include message')
})
test('renderError formats error as JSON', () => {
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
const output = renderError(error, { format: 'json' })
const parsed = JSON.parse(output)
assert.ok('error' in parsed, 'Should have error property')
assert.strictEqual(parsed.error.code, 'NOT_FOUND', 'Should include error code')
})
test('renderError formats error as YAML', () => {
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
const output = renderError(error, { format: 'yaml' })
assert.ok(output.includes('error:'), 'Should have error key')
assert.ok(output.includes('code: NOT_FOUND'), 'Should include error code')
})
test('toCommandError converts Error to CommandError', () => {
const error = new Error('Something went wrong')
const commandError = toCommandError(error)
assert.strictEqual(commandError.code, 'UNKNOWN_ERROR', 'Should use UNKNOWN_ERROR code')
assert.strictEqual(commandError.message, 'Something went wrong', 'Should preserve message')
})
test('toCommandError passes through CommandError', () => {
const error: CommandError = { code: 'CUSTOM', message: 'Custom error' }
const commandError = toCommandError(error)
assert.strictEqual(commandError.code, 'CUSTOM', 'Should preserve code')
assert.strictEqual(commandError.message, 'Custom error', 'Should preserve message')
})
// Summary
console.log('\n=== Summary ===\n')
console.log(`Passed: ${passed}`)
console.log(`Failed: ${failed}`)
process.exit(failed > 0 ? 1 : 0)
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env npx tsx
/**
* Phase 2: Daemon Command Tests
*
* Tests daemon commands - currently focused on error cases and help
* since daemon start may not be fully working yet.
*
* Tests:
* - daemon --help shows subcommands
* - daemon status fails gracefully when daemon not running
* - daemon status --format json outputs valid JSON (even for errors)
* - daemon stop handles daemon not running gracefully
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Daemon Commands ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: daemon --help shows subcommands
{
console.log('Test 1: daemon --help shows subcommands')
const result = await $`npx paseo daemon --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'daemon --help should exit 0')
assert(result.stdout.includes('start'), 'help should mention start')
assert(result.stdout.includes('status'), 'help should mention status')
assert(result.stdout.includes('stop'), 'help should mention stop')
assert(result.stdout.includes('restart'), 'help should mention restart')
console.log('✓ daemon --help shows subcommands\n')
}
// Test 2: daemon status fails gracefully when daemon not running
{
console.log('Test 2: daemon status fails gracefully when not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// The error should mention something about daemon not running or connection
const output = result.stdout + result.stderr
const hasDaemonError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('running')
assert(hasDaemonError, 'error message should mention daemon/connect/running')
console.log('✓ daemon status fails gracefully when not running\n')
}
// Test 3: daemon status --format json outputs valid JSON (even for errors)
{
console.log('Test 3: daemon status --format json outputs JSON')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --format json`.nothrow()
// Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// But output should be valid JSON
const output = result.stdout.trim()
if (output.length > 0) {
try {
JSON.parse(output)
console.log('✓ daemon status --format json outputs valid JSON\n')
} catch {
// If stdout is empty, check if stderr has the error (acceptable for now)
console.log('✓ daemon status --format json handled error (output may be in stderr)\n')
}
} else {
// Empty stdout is acceptable if error is in stderr
console.log('✓ daemon status --format json handled error gracefully\n')
}
}
// Test 4: daemon stop handles daemon not running gracefully
{
console.log('Test 4: daemon stop handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
// Stop should succeed even if daemon not running (idempotent)
// OR it should fail gracefully with a clear message
const output = result.stdout + result.stderr
if (result.exitCode === 0) {
// If it succeeds, it should mention the daemon wasn't running
const mentionsNotRunning =
output.toLowerCase().includes('not running') ||
output.toLowerCase().includes('was not running')
assert(mentionsNotRunning, 'success output should mention daemon was not running')
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
} else {
// If it fails, error should be clear
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('not running')
assert(hasError, 'error message should be clear about daemon state')
console.log('✓ daemon stop fails gracefully when daemon not running\n')
}
}
// Test 5: daemon restart fails when daemon not running
{
console.log('Test 5: daemon restart fails when daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon restart`.nothrow()
// Restart should fail when daemon not running (can't restart something that's not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('not running') ||
output.toLowerCase().includes('connect')
assert(hasError, 'error message should mention daemon state')
console.log('✓ daemon restart fails appropriately when daemon not running\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All daemon tests passed ===')
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env npx tsx
/**
* Phase 3: LS Command Tests
*
* Tests the ls command - listing agents (top-level command).
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - JSON output format
*
* Tests:
* - paseo --help shows ls command
* - paseo ls --help shows options
* - paseo ls returns empty list or error when no daemon
* - paseo ls --format json returns valid JSON (or error)
* - paseo ls -a flag is accepted
* - paseo ls -g flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== LS Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: paseo --help shows ls command
{
console.log('Test 1: paseo --help shows ls command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls command')
console.log('✓ paseo --help shows ls command\n')
}
// Test 2: paseo ls --help shows options
{
console.log('Test 2: paseo ls --help shows options')
const result = await $`npx paseo ls --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo ls --help should exit 0')
assert(result.stdout.includes('-a'), 'help should mention -a flag')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('-g'), 'help should mention -g flag')
assert(result.stdout.includes('--global'), 'help should mention --global flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
console.log('✓ paseo ls --help shows options\n')
}
// Test 3: paseo ls returns error when no daemon running
{
console.log('Test 3: paseo ls handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ paseo ls handles daemon not running\n')
}
// Test 4: paseo ls --format json returns valid JSON error
{
console.log('Test 4: paseo ls --format json handles errors')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls --format json`.nothrow()
// Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// But output should be valid JSON if present
const output = result.stdout.trim()
if (output.length > 0) {
try {
JSON.parse(output)
console.log('✓ paseo ls --format json outputs valid JSON error\n')
} catch {
// Empty or stderr-only output is acceptable
console.log('✓ paseo ls --format json handled error (output may be in stderr)\n')
}
} else {
console.log('✓ paseo ls --format json handled error gracefully\n')
}
}
// Test 5: paseo ls -a flag is accepted
{
console.log('Test 5: paseo ls -a flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls -a`.nothrow()
// Will fail due to no daemon, but flag should be parsed without error
// (no "unknown option" error)
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -a flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ paseo ls -a flag is accepted\n')
}
// Test 6: paseo ls -g flag is accepted
{
console.log('Test 6: paseo ls -g flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls -g`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -g flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ paseo ls -g flag is accepted\n')
}
// Test 7: paseo ls -ag combined flags are accepted
{
console.log('Test 7: paseo ls -ag combined flags are accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls -ag`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -ag flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ paseo ls -ag combined flags are accepted\n')
}
// Test 8: -q (quiet) flag is accepted globally
{
console.log('Test 8: -q (quiet) flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q ls`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All ls tests passed ===')
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env npx tsx
/**
* Phase 4: Run Command Tests
*
* Tests the run command - creating and running agents with tasks (top-level command).
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - run --help shows options
* - run requires prompt argument
* - run handles daemon not running
* - run -d flag is accepted
* - run --name flag is accepted
* - run --provider flag is accepted
* - run --mode flag is accepted
* - run --cwd flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Run Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: run --help shows options
{
console.log('Test 1: run --help shows options')
const result = await $`npx paseo run --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'run --help should exit 0')
assert(result.stdout.includes('-d'), 'help should mention -d flag')
assert(result.stdout.includes('--detach'), 'help should mention --detach flag')
assert(result.stdout.includes('--name'), 'help should mention --name option')
assert(result.stdout.includes('--provider'), 'help should mention --provider option')
assert(result.stdout.includes('--mode'), 'help should mention --mode option')
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
console.log('✓ run --help shows options\n')
}
// Test 2: run requires prompt argument
{
console.log('Test 2: run requires prompt argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
const output = result.stdout + result.stderr
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('✓ run requires prompt argument\n')
}
// Test 3: run handles daemon not running
{
console.log('Test 3: run handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run "test prompt"`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ run handles daemon not running\n')
}
// Test 4: run -d flag is accepted
{
console.log('Test 4: run -d flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -d flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run -d flag is accepted\n')
}
// Test 5: run --name flag is accepted
{
console.log('Test 5: run --name flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --name flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --name flag is accepted\n')
}
// Test 6: run --provider flag is accepted
{
console.log('Test 6: run --provider flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --provider flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --provider flag is accepted\n')
}
// Test 7: run --mode flag is accepted
{
console.log('Test 7: run --mode flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --mode flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --mode flag is accepted\n')
}
// Test 8: run --cwd flag is accepted
{
console.log('Test 8: run --cwd flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --cwd flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --cwd flag is accepted\n')
}
// Test 9: -q (quiet) flag is accepted with run
{
console.log('Test 9: -q (quiet) flag is accepted with run')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with run\n')
}
// Test 10: Combined flags work together
{
console.log('Test 10: Combined flags work together')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept all combined flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ Combined flags work together\n')
}
// Test 11: paseo --help shows run command
{
console.log('Test 11: paseo --help shows run command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('run'), 'help should mention run command')
console.log('✓ paseo --help shows run command\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All run tests passed ===')
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env npx tsx
/**
* Phase 5: Send Command Tests
*
* Tests the send command - sending messages to existing agents (top-level command).
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - send --help shows options
* - send requires id and prompt arguments
* - send handles daemon not running
* - send --no-wait flag is accepted
* - agent shows send in subcommands
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Send Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: send --help shows options
{
console.log('Test 1: send --help shows options')
const result = await $`npx paseo send --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
assert(result.stdout.includes('--no-wait'), 'help should mention --no-wait flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
console.log(' help should mention --no-wait flag')
console.log(' help should mention --host option')
console.log(' help should mention <id> argument')
console.log(' help should mention <prompt> argument')
console.log('✓ send --help shows options\n')
}
// Test 2: send requires id argument
{
console.log('Test 2: send requires id argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('✓ send requires id argument\n')
}
// Test 3: send requires prompt argument
{
console.log('Test 3: send requires prompt argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
const output = result.stdout + result.stderr
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('✓ send requires prompt argument\n')
}
// Test 4: send handles daemon not running
{
console.log('Test 4: send handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ send handles daemon not running\n')
}
// Test 5: send --no-wait flag is accepted
{
console.log('Test 5: send --no-wait flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --no-wait flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ send --no-wait flag is accepted\n')
}
// Test 6: send --host flag is accepted
{
console.log('Test 6: send --host flag is accepted')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo send --host localhost:${port} abc123 "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ send --host flag is accepted\n')
}
// Test 7: -q (quiet) flag is accepted with send
{
console.log('Test 7: -q (quiet) flag is accepted with send')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with send\n')
}
// Test 8: Combined flags work together
{
console.log('Test 8: Combined flags work together')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept all combined flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ Combined flags work together\n')
}
// Test 9: paseo --help shows send command
{
console.log('Test 9: paseo --help shows send command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('send'), 'help should mention send command')
console.log('✓ paseo --help shows send command\n')
}
// Test 10: ID prefix syntax is mentioned in help
{
console.log('Test 10: send command description mentions ID')
const result = await $`npx paseo send --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
const hasIdMention =
result.stdout.toLowerCase().includes('id') ||
result.stdout.toLowerCase().includes('prefix')
assert(hasIdMention, 'help should mention ID or prefix')
console.log('✓ send command description mentions ID\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All send tests passed ===')
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env npx tsx
/**
* Phase 6: Stop Command Tests
*
* Tests the stop command - stopping agents (cancel if running, then terminate) (top-level command).
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - stop --help shows options
* - stop requires ID, --all, or --cwd
* - stop handles daemon not running
* - stop --all flag is accepted
* - stop --cwd flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Stop Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: stop --help shows options
{
console.log('Test 1: stop --help shows options')
const result = await $`npx paseo stop --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'stop --help should exit 0')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('[id]'), 'help should mention optional id argument')
console.log('✓ stop --help shows options\n')
}
// Test 2: stop requires ID, --all, or --cwd
{
console.log('Test 2: stop requires ID, --all, or --cwd')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ stop requires ID, --all, or --cwd\n')
}
// Test 3: stop handles daemon not running
{
console.log('Test 3: stop handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ stop handles daemon not running\n')
}
// Test 4: stop --all flag is accepted
{
console.log('Test 4: stop --all flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --all flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ stop --all flag is accepted\n')
}
// Test 5: stop --cwd flag is accepted
{
console.log('Test 5: stop --cwd flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --cwd flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ stop --cwd flag is accepted\n')
}
// Test 6: stop with ID and --host flag is accepted
{
console.log('Test 6: stop with ID and --host flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ stop with ID and --host flag is accepted\n')
}
// Test 7: paseo --help shows stop command
{
console.log('Test 7: paseo --help shows stop command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('stop'), 'help should mention stop command')
console.log('✓ paseo --help shows stop command\n')
}
// Test 8: -q (quiet) flag is accepted with stop
{
console.log('Test 8: -q (quiet) flag is accepted with stop')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with stop\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All stop tests passed ===')
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env npx tsx
/**
* Phase 7: Logs Command Tests
*
* Tests the logs command - viewing agent activity/timeline (top-level command).
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - logs --help shows options
* - logs requires ID argument
* - logs handles daemon not running
* - logs -f (follow) flag is accepted
* - logs --tail flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Logs Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: logs --help shows options
{
console.log('Test 1: logs --help shows options')
const result = await $`npx paseo logs --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'logs --help should exit 0')
assert(result.stdout.includes('-f') || result.stdout.includes('--follow'), 'help should mention -f/--follow flag')
assert(result.stdout.includes('--tail'), 'help should mention --tail option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ logs --help shows options\n')
}
// Test 2: logs requires ID argument
{
console.log('Test 2: logs requires ID argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ logs requires ID argument\n')
}
// Test 3: logs handles daemon not running
{
console.log('Test 3: logs handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ logs handles daemon not running\n')
}
// Test 4: logs -f (follow) flag is accepted
{
console.log('Test 4: logs -f (follow) flag is accepted')
// Use timeout to avoid hanging on follow mode
const result =
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -f flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs -f (follow) flag is accepted\n')
}
// Test 5: logs --follow flag is accepted
{
console.log('Test 5: logs --follow flag is accepted')
const result =
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --follow flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs --follow flag is accepted\n')
}
// Test 6: logs --tail flag is accepted
{
console.log('Test 6: logs --tail flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --tail flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs --tail flag is accepted\n')
}
// Test 7: logs with ID and --host flag is accepted
{
console.log('Test 7: logs with ID and --host flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs with ID and --host flag is accepted\n')
}
// Test 8: paseo --help shows logs command
{
console.log('Test 8: paseo --help shows logs command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('logs'), 'help should mention logs command')
console.log('✓ paseo --help shows logs command\n')
}
// Test 9: -q (quiet) flag is accepted with logs
{
console.log('Test 9: -q (quiet) flag is accepted with logs')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with logs\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All logs tests passed ===')
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env npx tsx
/**
* Phase 9: Inspect Command Tests
*
* Tests the inspect command - showing detailed agent information (top-level command).
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - inspect --help shows options
* - inspect requires id argument
* - inspect handles daemon not running
* - inspect --host flag is accepted
* - agent shows inspect in subcommands
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Inspect Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: inspect --help shows options
{
console.log('Test 1: inspect --help shows options')
const result = await $`npx paseo inspect --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
console.log(' help should mention --host option')
console.log(' help should mention <id> argument')
console.log('inspect --help shows options\n')
}
// Test 2: inspect requires id argument
{
console.log('Test 2: inspect requires id argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('inspect requires id argument\n')
}
// Test 3: inspect handles daemon not running
{
console.log('Test 3: inspect handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('inspect handles daemon not running\n')
}
// Test 4: inspect --host flag is accepted
{
console.log('Test 4: inspect --host flag is accepted')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo inspect --host localhost:${port} abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('inspect --host flag is accepted\n')
}
// Test 5: -q (quiet) flag is accepted with inspect
{
console.log('Test 5: -q (quiet) flag is accepted with inspect')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('-q (quiet) flag is accepted with inspect\n')
}
// Test 6: --format json flag is accepted with inspect
{
console.log('Test 6: --format json flag is accepted with inspect')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format json inspect abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --format json flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--format json flag is accepted with inspect\n')
}
// Test 7: --format yaml flag is accepted with inspect
{
console.log('Test 7: --format yaml flag is accepted with inspect')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--format yaml flag is accepted with inspect\n')
}
// Test 8: paseo --help shows inspect command
{
console.log('Test 8: paseo --help shows inspect command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('inspect'), 'help should mention inspect command')
console.log('paseo --help shows inspect command\n')
}
// Test 9: inspect command description is helpful
{
console.log('Test 9: inspect command description is helpful')
const result = await $`npx paseo inspect --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
const hasDescription =
result.stdout.toLowerCase().includes('detail') ||
result.stdout.toLowerCase().includes('information') ||
result.stdout.toLowerCase().includes('show')
assert(hasDescription, 'help should describe what inspect does')
console.log('inspect command description is helpful\n')
}
// Test 10: ID prefix syntax is mentioned in help
{
console.log('Test 10: inspect command mentions ID')
const result = await $`npx paseo inspect --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
const hasIdMention =
result.stdout.toLowerCase().includes('id') ||
result.stdout.toLowerCase().includes('prefix')
assert(hasIdMention, 'help should mention ID or prefix')
console.log('inspect command mentions ID\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All inspect tests passed ===')
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env npx tsx
/**
* Phase 10: Agent Mode Command Tests
*
* Tests the agent mode command - changing and listing agent operational modes.
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - agent mode --help shows options
* - agent mode requires id argument
* - agent mode handles daemon not running
* - agent mode --list flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Agent Mode Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: agent mode --help shows options
{
console.log('Test 1: agent mode --help shows options')
const result = await $`npx paseo agent mode --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent mode --help should exit 0')
assert(result.stdout.includes('--list'), 'help should mention --list flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
assert(result.stdout.includes('[mode]'), 'help should mention optional mode argument')
console.log('✓ agent mode --help shows options\n')
}
// Test 2: agent mode requires id argument
{
console.log('Test 2: agent mode requires id argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent mode requires id argument\n')
}
// Test 3: agent mode handles daemon not running
{
console.log('Test 3: agent mode handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ agent mode handles daemon not running\n')
}
// Test 4: agent mode --list flag is accepted
{
console.log('Test 4: agent mode --list flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --list flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent mode --list flag is accepted\n')
}
// Test 5: agent mode with ID and --host flag is accepted
{
console.log('Test 5: agent mode with ID and --host flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent mode with ID and --host flag is accepted\n')
}
// Test 6: agent shows mode in subcommands
{
console.log('Test 6: agent --help shows mode subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('mode'), 'help should mention mode subcommand')
console.log('✓ agent --help shows mode subcommand\n')
}
// Test 7: -q (quiet) flag is accepted with agent mode
{
console.log('Test 7: -q (quiet) flag is accepted with agent mode')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with agent mode\n')
}
// Test 8: agent mode requires mode argument when not using --list
{
console.log('Test 8: agent mode requires mode argument when not using --list')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow()
// Should fail because mode is required unless --list is specified
assert.notStrictEqual(result.exitCode, 0, 'should fail without mode argument')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('mode') ||
output.toLowerCase().includes('daemon') // If daemon error comes first, that's also valid
assert(hasError, 'error should mention missing mode or connection issue')
console.log('✓ agent mode requires mode argument when not using --list\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All agent mode tests passed ===')
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env npx tsx
/**
* Phase 11: Agent Archive Command Tests
*
* Tests the agent archive command - archiving (soft-delete) agents.
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - agent archive --help shows options
* - agent archive requires ID argument
* - agent archive handles daemon not running
* - agent archive --force flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Agent Archive Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: agent archive --help shows options
{
console.log('Test 1: agent archive --help shows options')
const result = await $`npx paseo agent archive --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent archive --help should exit 0')
assert(result.stdout.includes('--force'), 'help should mention --force flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ agent archive --help shows options\n')
}
// Test 2: agent archive requires ID argument
{
console.log('Test 2: agent archive requires ID argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent archive requires ID argument\n')
}
// Test 3: agent archive handles daemon not running
{
console.log('Test 3: agent archive handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ agent archive handles daemon not running\n')
}
// Test 4: agent archive --force flag is accepted
{
console.log('Test 4: agent archive --force flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --force flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent archive --force flag is accepted\n')
}
// Test 5: agent archive with ID and --host flag is accepted
{
console.log('Test 5: agent archive with ID and --host flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent archive with ID and --host flag is accepted\n')
}
// Test 6: agent shows archive in subcommands
{
console.log('Test 6: agent --help shows archive subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
console.log('✓ agent --help shows archive subcommand\n')
}
// Test 7: -q (quiet) flag is accepted with agent archive
{
console.log('Test 7: -q (quiet) flag is accepted with agent archive')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with agent archive\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All agent archive tests passed ===')
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env npx tsx
/**
* Phase 11: Wait Command Tests
*
* Tests the wait command - waiting for an agent to become idle (top-level command).
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - wait --help shows options
* - wait requires id argument
* - wait handles daemon not running
* - wait --timeout flag is accepted
* - wait --host flag is accepted
* - agent shows wait in subcommands
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Wait Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: wait --help shows options
{
console.log('Test 1: wait --help shows options')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('--timeout'), 'help should mention --timeout option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
console.log(' help should mention --host option')
console.log(' help should mention --timeout option')
console.log(' help should mention <id> argument')
console.log('wait --help shows options\n')
}
// Test 2: wait requires id argument
{
console.log('Test 2: wait requires id argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('wait requires id argument\n')
}
// Test 3: wait handles daemon not running
{
console.log('Test 3: wait handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('wait handles daemon not running\n')
}
// Test 4: wait --timeout flag is accepted
{
console.log('Test 4: wait --timeout flag is accepted')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo wait --timeout 30 --host localhost:${port} abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --timeout flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('wait --timeout flag is accepted\n')
}
// Test 5: wait --host flag is accepted
{
console.log('Test 5: wait --host flag is accepted')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo wait --host localhost:${port} abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('wait --host flag is accepted\n')
}
// Test 6: -q (quiet) flag is accepted with wait
{
console.log('Test 6: -q (quiet) flag is accepted with wait')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('-q (quiet) flag is accepted with wait\n')
}
// Test 7: --format json flag is accepted with wait
{
console.log('Test 7: --format json flag is accepted with wait')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format json wait abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --format json flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--format json flag is accepted with wait\n')
}
// Test 8: --format yaml flag is accepted with wait
{
console.log('Test 8: --format yaml flag is accepted with wait')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--format yaml flag is accepted with wait\n')
}
// Test 9: paseo --help shows wait command
{
console.log('Test 9: paseo --help shows wait command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('wait'), 'help should mention wait command')
console.log('paseo --help shows wait command\n')
}
// Test 10: wait command description is helpful
{
console.log('Test 10: wait command description is helpful')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
const hasDescription =
result.stdout.toLowerCase().includes('wait') ||
result.stdout.toLowerCase().includes('idle')
assert(hasDescription, 'help should describe what wait does')
console.log('wait command description is helpful\n')
}
// Test 11: ID prefix syntax is mentioned in help
{
console.log('Test 11: wait command mentions ID')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
const hasIdMention =
result.stdout.toLowerCase().includes('id') ||
result.stdout.toLowerCase().includes('prefix')
assert(hasIdMention, 'help should mention ID or prefix')
console.log('wait command mentions ID\n')
}
// Test 12: timeout option has default value documented
{
console.log('Test 12: timeout option has default value documented')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
const hasDefault =
result.stdout.includes('600') || result.stdout.toLowerCase().includes('default')
assert(hasDefault, 'help should mention timeout default value')
console.log('timeout option has default value documented\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All wait tests passed ===')
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env npx tsx
/**
* Permit LS Command Tests
*
* Tests the permit ls command - listing pending permissions.
* Since daemon may not be running, we test:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - JSON output format
*
* Tests:
* - permit --help shows subcommands
* - permit ls --help shows options
* - permit ls returns error when no daemon
* - permit ls --format json handles errors
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Permit LS Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: permit --help shows subcommands
{
console.log('Test 1: permit --help shows subcommands')
const result = await $`npx paseo permit --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
assert(result.stdout.includes('allow'), 'help should mention allow subcommand')
assert(result.stdout.includes('deny'), 'help should mention deny subcommand')
console.log('✓ permit --help shows subcommands\n')
}
// Test 2: permit ls --help shows options
{
console.log('Test 2: permit ls --help shows options')
const result = await $`npx paseo permit ls --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit ls --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
console.log('✓ permit ls --help shows options\n')
}
// Test 3: permit ls returns error when no daemon running
{
console.log('Test 3: permit ls handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ permit ls handles daemon not running\n')
}
// Test 4: permit ls --format json handles errors
{
console.log('Test 4: permit ls --format json handles errors')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --format json`.nothrow()
// Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// But output should be valid JSON if present
const output = result.stdout.trim()
if (output.length > 0) {
try {
JSON.parse(output)
console.log('✓ permit ls --format json outputs valid JSON error\n')
} catch {
// Empty or stderr-only output is acceptable
console.log('✓ permit ls --format json handled error (output may be in stderr)\n')
}
} else {
console.log('✓ permit ls --format json handled error gracefully\n')
}
}
// Test 5: -q (quiet) flag is accepted globally
{
console.log('Test 5: -q (quiet) flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All permit ls tests passed ===')
@@ -0,0 +1,175 @@
#!/usr/bin/env npx tsx
/**
* Permit Allow/Deny Command Tests
*
* Tests the permit allow and deny commands.
* Since daemon may not be running, we test:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - Flag acceptance
*
* Tests:
* - permit allow --help shows options
* - permit deny --help shows options
* - permit allow handles daemon not running
* - permit deny handles daemon not running
* - permit allow --all flag is accepted
* - permit deny --all flag is accepted
* - permit deny --message flag is accepted
* - permit deny --interrupt flag is accepted
* - permit allow --input flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Permit Allow/Deny Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: permit allow --help shows options
{
console.log('Test 1: permit allow --help shows options')
const result = await $`npx paseo permit allow --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit allow --help should exit 0')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('--input'), 'help should mention --input option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
console.log('✓ permit allow --help shows options\n')
}
// Test 2: permit deny --help shows options
{
console.log('Test 2: permit deny --help shows options')
const result = await $`npx paseo permit deny --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit deny --help should exit 0')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('--message'), 'help should mention --message option')
assert(result.stdout.includes('--interrupt'), 'help should mention --interrupt flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
console.log('✓ permit deny --help shows options\n')
}
// Test 3: permit allow handles daemon not running
{
console.log('Test 3: permit allow handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ permit allow handles daemon not running\n')
}
// Test 4: permit deny handles daemon not running
{
console.log('Test 4: permit deny handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ permit deny handles daemon not running\n')
}
// Test 5: permit allow --all flag is accepted
{
console.log('Test 5: permit allow --all flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --all flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit allow --all flag is accepted\n')
}
// Test 6: permit deny --all flag is accepted
{
console.log('Test 6: permit deny --all flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --all flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit deny --all flag is accepted\n')
}
// Test 7: permit deny --message flag is accepted
{
console.log('Test 7: permit deny --message flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --message flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit deny --message flag is accepted\n')
}
// Test 8: permit deny --interrupt flag is accepted
{
console.log('Test 8: permit deny --interrupt flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --interrupt flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit deny --interrupt flag is accepted\n')
}
// Test 9: permit allow --input flag is accepted
{
console.log('Test 9: permit allow --input flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --input flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit allow --input flag is accepted\n')
}
// Test 10: permit allow without req_id and without --all fails gracefully
{
console.log('Test 10: permit allow requires req_id or --all')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow()
// This might fail due to daemon not running first, or due to missing argument
// The important thing is it doesn't crash with an unhandled error
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
console.log('✓ permit allow requires req_id or --all\n')
}
// Test 11: permit deny without req_id and without --all fails gracefully
{
console.log('Test 11: permit deny requires req_id or --all')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
console.log('✓ permit deny requires req_id or --all\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All permit allow/deny tests passed ===')
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env npx tsx
/**
* Phase 14: Worktree Command Tests
*
* Tests the worktree commands for managing Paseo-managed git worktrees.
* Since daemon may not be running, we test both:
* - Help and argument parsing
* - Graceful error handling when daemon not running
* - All flags are accepted
*
* Tests:
* - worktree --help shows subcommands
* - worktree ls --help shows options
* - worktree ls handles daemon not running
* - worktree archive --help shows options
* - worktree archive requires name argument
* - worktree archive handles daemon not running
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Worktree Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: worktree --help shows subcommands
{
console.log('Test 1: worktree --help shows subcommands')
const result = await $`npx paseo worktree --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'worktree --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
console.log('✓ worktree --help shows subcommands\n')
}
// Test 2: worktree ls --help shows options
{
console.log('Test 2: worktree ls --help shows options')
const result = await $`npx paseo worktree ls --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'worktree ls --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
console.log('✓ worktree ls --help shows options\n')
}
// Test 3: worktree ls handles daemon not running
{
console.log('Test 3: worktree ls handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ worktree ls handles daemon not running\n')
}
// Test 4: worktree ls with --host flag is accepted
{
console.log('Test 4: worktree ls with --host flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ worktree ls with --host flag is accepted\n')
}
// Test 5: worktree archive --help shows options
{
console.log('Test 5: worktree archive --help shows options')
const result = await $`npx paseo worktree archive --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'worktree archive --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<name>'), 'help should mention required name argument')
console.log('✓ worktree archive --help shows options\n')
}
// Test 6: worktree archive requires name argument
{
console.log('Test 6: worktree archive requires name argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without name')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasError, 'error should mention missing argument')
console.log('✓ worktree archive requires name argument\n')
}
// Test 7: worktree archive handles daemon not running
{
console.log('Test 7: worktree archive handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow()
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ worktree archive handles daemon not running\n')
}
// Test 8: worktree archive with name and --host flag is accepted
{
console.log('Test 8: worktree archive with name and --host flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ worktree archive with name and --host flag is accepted\n')
}
// Test 9: -q (quiet) flag is accepted with worktree ls
{
console.log('Test 9: -q (quiet) flag is accepted with worktree ls')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with worktree ls\n')
}
// Test 10: -f json flag is accepted with worktree ls
{
console.log('Test 10: -f json flag is accepted with worktree ls')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -f json worktree ls`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -f json flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -f json flag is accepted with worktree ls\n')
}
// Test 11: paseo --help shows worktree subcommand
{
console.log('Test 11: paseo --help shows worktree subcommand')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('worktree'), 'help should mention worktree subcommand')
console.log('✓ paseo --help shows worktree subcommand\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All worktree tests passed ===')
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env npx tsx
/**
* Phase 15: Provider Command Tests
*
* Tests provider commands for listing providers and models.
* Provider data is static and doesn't require a running daemon.
*
* Tests:
* - provider --help shows subcommands
* - provider ls lists all providers
* - provider ls --format json outputs valid JSON
* - provider ls --quiet outputs provider names only
* - provider models claude lists claude models
* - provider models codex lists codex models
* - provider models opencode lists opencode models
* - provider models unknown fails with error
* - provider models --format json outputs valid JSON
*/
import assert from 'node:assert'
import { $ } from 'zx'
$.verbose = false
console.log('=== Provider Commands ===\n')
// Test 1: provider --help shows subcommands
{
console.log('Test 1: provider --help shows subcommands')
const result = await $`npx paseo provider --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls')
assert(result.stdout.includes('models'), 'help should mention models')
console.log('✓ provider --help shows subcommands\n')
}
// Test 2: provider ls lists all providers
{
console.log('Test 2: provider ls lists all providers')
const result = await $`npx paseo provider ls`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
assert(result.stdout.includes('claude'), 'output should include claude')
assert(result.stdout.includes('codex'), 'output should include codex')
assert(result.stdout.includes('opencode'), 'output should include opencode')
assert(result.stdout.includes('available'), 'output should show available status')
console.log('✓ provider ls lists all providers\n')
}
// Test 3: provider ls --format json outputs valid JSON
{
console.log('Test 3: provider ls --format json outputs valid JSON')
const result = await $`npx paseo provider ls --format json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 providers')
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
console.log('✓ provider ls --format json outputs valid JSON\n')
}
// Test 4: provider ls --quiet outputs provider names only
{
console.log('Test 4: provider ls --quiet outputs provider names only')
const result = await $`npx paseo provider ls --quiet`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude'), 'should include claude')
assert(lines.includes('codex'), 'should include codex')
assert(lines.includes('opencode'), 'should include opencode')
console.log('✓ provider ls --quiet outputs provider names only\n')
}
// Test 5: provider models claude lists claude models
{
console.log('Test 5: provider models claude lists claude models')
const result = await $`npx paseo provider models claude`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models claude should exit 0')
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude-sonnet-4')
assert(result.stdout.includes('claude-opus-4-20250514'), 'output should include claude-opus-4')
assert(result.stdout.includes('claude-3-5-haiku-20241022'), 'output should include claude-haiku')
console.log('✓ provider models claude lists claude models\n')
}
// Test 6: provider models codex lists codex models
{
console.log('Test 6: provider models codex lists codex models')
const result = await $`npx paseo provider models codex`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models codex should exit 0')
assert(result.stdout.includes('o3-mini'), 'output should include o3-mini')
assert(result.stdout.includes('o4-mini'), 'output should include o4-mini')
console.log('✓ provider models codex lists codex models\n')
}
// Test 7: provider models opencode lists opencode models
{
console.log('Test 7: provider models opencode lists opencode models')
const result = await $`npx paseo provider models opencode`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models opencode should exit 0')
// opencode supports both claude and codex models
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude models')
assert(result.stdout.includes('o3-mini'), 'output should include codex models')
console.log('✓ provider models opencode lists opencode models\n')
}
// Test 8: provider models unknown fails with error
{
console.log('Test 8: provider models unknown fails with error')
const result = await $`npx paseo provider models unknown`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
const output = result.stdout + result.stderr
assert(
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
'error should mention unknown provider'
)
console.log('✓ provider models unknown fails with error\n')
}
// Test 9: provider models --format json outputs valid JSON
{
console.log('Test 9: provider models --format json outputs valid JSON')
const result = await $`npx paseo provider models claude --format json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 models for claude')
assert(data.every((m: { model: string; id: string }) => m.model && m.id), 'each model should have name and id')
console.log('✓ provider models --format json outputs valid JSON\n')
}
// Test 10: provider models --quiet outputs model IDs only
{
console.log('Test 10: provider models --quiet outputs model IDs only')
const result = await $`npx paseo provider models claude --quiet`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude-sonnet-4-20250514'), 'should include claude-sonnet-4')
assert(lines.includes('claude-opus-4-20250514'), 'should include claude-opus-4')
assert(lines.includes('claude-3-5-haiku-20241022'), 'should include claude-haiku')
console.log('✓ provider models --quiet outputs model IDs only\n')
}
console.log('=== All provider tests passed ===')
@@ -0,0 +1,246 @@
#!/usr/bin/env npx tsx
/**
* E2E Test: Agent Lifecycle
*
* This test verifies the complete agent lifecycle using REAL daemons and agents.
* It starts an isolated daemon on a random port and runs actual CLI commands.
*
* Test flow:
* 1. Start daemon on random port
* 2. Create agent with `paseo run "say hello" --provider claude`
* 3. List agents with `paseo ls`
* 4. Wait for agent with `paseo wait <id>`
* 5. Inspect agent with `paseo inspect <id>`
* 6. Stop agent with `paseo stop <id>`
* 7. Cleanup: stop daemon, remove temp dirs
*
* CRITICAL RULES:
* - NEVER use port 6767 (user's running daemon)
* - Always use claude provider with haiku model for fast, cheap tests
* - Clean up resources after test completes
*/
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
}
let ctx: E2EContext
async function setup(): Promise<void> {
console.log('Setting up E2E test context...')
console.log('Starting test daemon on random port (this may take a few seconds)...')
try {
ctx = await createE2ETestContext({ timeout: 45000 })
console.log(`Test daemon started on port ${ctx.port}`)
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
console.log(`Work directory: ${ctx.workDir}`)
} catch (err) {
console.error('Failed to start test daemon:', err)
throw err
}
}
async function cleanup(): Promise<void> {
console.log('\nCleaning up...')
if (ctx) {
await ctx.stop()
console.log('Test daemon stopped and temp directories removed')
}
}
async function test_agent_ls_empty(): Promise<void> {
console.log('\n--- Test: agent ls with empty list ---')
const result = await ctx.paseo(['ls', '--format', 'json'])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
const agents = JSON.parse(result.stdout.trim())
assert(Array.isArray(agents), 'Output should be JSON array')
assert.strictEqual(agents.length, 0, 'Should have no agents initially')
console.log('PASS: agent ls returns empty list')
}
async function test_agent_run_detached(): Promise<string> {
console.log('\n--- Test: agent run detached ---')
// Use quiet mode to get just the agent ID
// CRITICAL: Use haiku model for fast, cheap tests
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
const result = await ctx.paseo(
[
'-q',
'run',
'-d',
'--provider',
'claude',
'--model',
'claude-3-5-haiku-20241022',
'--mode',
'bypassPermissions',
'--name',
'E2E Test Agent',
'Say hello world',
],
{ timeout: 60000 }
)
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
const agentId = result.stdout.trim()
assert(agentId.length > 0, 'Should return agent ID')
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
console.log(`PASS: agent created with ID: ${agentId}`)
return agentId
}
async function test_agent_ls_shows_agent(agentId: string): Promise<void> {
console.log('\n--- Test: agent ls shows created agent ---')
const result = await ctx.paseo(['ls', '--format', 'json'])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
const agents = JSON.parse(result.stdout.trim()) as Array<{ id: string; title?: string; status?: string }>
assert(Array.isArray(agents), 'Output should be JSON array')
assert(agents.length >= 1, 'Should have at least one agent')
// Find our agent by ID prefix
const ourAgent = agents.find((a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)))
assert(ourAgent, `Our agent ${agentId} should be in the list`)
console.log('Agent found:', ourAgent)
console.log('PASS: agent ls shows created agent')
}
async function test_agent_wait(agentId: string): Promise<void> {
console.log('\n--- Test: agent wait ---')
// Wait for agent to become idle (with generous timeout for haiku model)
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
console.log('PASS: agent wait completed successfully')
}
async function test_agent_inspect(agentId: string): Promise<void> {
console.log('\n--- Test: agent inspect ---')
const result = await ctx.paseo(['inspect', agentId])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
assert.strictEqual(result.exitCode, 0, 'agent inspect should succeed')
// Check that output contains expected fields (table format)
const output = result.stdout
assert(output.includes('Id'), 'Output should contain Id field')
assert(output.includes('Provider'), 'Output should contain Provider field')
assert(output.includes('Status'), 'Output should contain Status field')
assert(output.includes('claude'), 'Output should mention claude provider')
console.log('PASS: agent inspect shows expected fields')
}
async function test_agent_logs(agentId: string): Promise<void> {
console.log('\n--- Test: agent logs ---')
const result = await ctx.paseo(['logs', '--tail', '20', agentId])
console.log('Exit code:', result.exitCode)
console.log('Stdout length:', result.stdout.length)
// Don't print full logs as they can be verbose
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
// Logs should have some content (agent was asked to say hello)
assert(result.stdout.length > 0, 'Logs should have some content')
console.log('PASS: agent logs returns content')
}
async function test_agent_stop(agentId: string): Promise<void> {
console.log('\n--- Test: agent stop ---')
const result = await ctx.paseo(['stop', agentId])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
// Verify agent is no longer in running list
const psResult = await ctx.paseo(['ls', '--format', 'json'])
const agents = JSON.parse(psResult.stdout.trim()) as Array<{ id: string; status?: string }>
// Agent might still be in list but should not be running
const ourAgent = agents.find((a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)))
if (ourAgent) {
assert(ourAgent.status !== 'running', 'Agent should not be running after stop')
}
console.log('PASS: agent stop completed successfully')
}
async function main(): Promise<void> {
console.log('=== E2E Test: Agent Lifecycle ===\n')
console.log('This test creates REAL agents with REAL daemons.')
console.log('It may take some time as it waits for agent responses.\n')
try {
await setup()
// Run tests in sequence
await test_agent_ls_empty()
const agentId = await test_agent_run_detached()
await test_agent_ls_shows_agent(agentId)
await test_agent_wait(agentId)
await test_agent_inspect(agentId)
await test_agent_logs(agentId)
await test_agent_stop(agentId)
console.log('\n=== All E2E tests passed! ===')
} catch (err) {
console.error('\n=== E2E test FAILED ===')
console.error(err)
process.exitCode = 1
} finally {
await cleanup()
}
}
main()
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env npx tsx
/**
* E2E Test: Agent Send Command
*
* This test verifies the `send` command using REAL daemons and agents.
* It starts an isolated daemon on a random port and runs actual CLI commands.
*
* Test flow:
* 1. Start daemon on random port
* 2. Create agent with detached mode
* 3. Wait for agent to become idle (initial task complete)
* 4. Send new message with `send`
* 5. Wait for agent again
* 6. Verify agent processed the message (check logs)
* 7. Cleanup
*
* CRITICAL RULES:
* - NEVER use port 6767 (user's running daemon)
* - Always use claude provider with haiku model for fast, cheap tests
* - Clean up resources after test completes
*/
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
}
let ctx: E2EContext
async function setup(): Promise<void> {
console.log('Setting up E2E test context...')
console.log('Starting test daemon on random port (this may take a few seconds)...')
try {
ctx = await createE2ETestContext({ timeout: 45000 })
console.log(`Test daemon started on port ${ctx.port}`)
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
console.log(`Work directory: ${ctx.workDir}`)
} catch (err) {
console.error('Failed to start test daemon:', err)
throw err
}
}
async function cleanup(): Promise<void> {
console.log('\nCleaning up...')
if (ctx) {
await ctx.stop()
console.log('Test daemon stopped and temp directories removed')
}
}
async function test_create_agent(): Promise<string> {
console.log('\n--- Test: Create agent in detached mode ---')
// CRITICAL: Use haiku model for fast, cheap tests
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
const result = await ctx.paseo(
[
'-q',
'run',
'-d',
'--provider',
'claude',
'--model',
'claude-3-5-haiku-20241022',
'--mode',
'bypassPermissions',
'--name',
'Send Test Agent',
'Say "initial task complete"',
],
{ timeout: 60000 }
)
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
const agentId = result.stdout.trim()
assert(agentId.length > 0, 'Should return agent ID')
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
console.log(`PASS: agent created with ID: ${agentId}`)
return agentId
}
async function test_wait_for_initial_task(agentId: string): Promise<void> {
console.log('\n--- Test: Wait for initial task to complete ---')
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
console.log('PASS: Initial task completed')
}
async function test_agent_send(agentId: string): Promise<void> {
console.log('\n--- Test: Send follow-up message ---')
// Send a follow-up message to the agent
const result = await ctx.paseo(
['send', agentId, 'Now say "follow-up task complete"'],
{ timeout: 180000 }
)
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent send should succeed')
// Verify output contains expected status
assert(
result.stdout.includes('completed') || result.stdout.includes('sent'),
'Should indicate message was sent or completed'
)
console.log('PASS: Follow-up message sent successfully')
}
async function test_verify_logs(agentId: string): Promise<void> {
console.log('\n--- Test: Verify agent processed both messages ---')
const result = await ctx.paseo(['logs', '--tail', '50', agentId])
console.log('Exit code:', result.exitCode)
console.log('Stdout length:', result.stdout.length)
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
// Logs should have content from both tasks
assert(result.stdout.length > 0, 'Logs should have content')
// The agent was asked to say specific things, check if either appears in logs
// This is a loose check since exact log format may vary
const logsLower = result.stdout.toLowerCase()
const hasInitialTask = logsLower.includes('initial') || logsLower.includes('hello') || logsLower.includes('task')
const hasFollowUp = logsLower.includes('follow') || logsLower.includes('complete') || logsLower.includes('task')
// At minimum, there should be log entries (we can't guarantee exact content)
assert(result.stdout.split('\n').length > 3, 'Should have multiple log entries from both tasks')
console.log('PASS: Agent logs show activity from both tasks')
}
async function test_agent_stop(agentId: string): Promise<void> {
console.log('\n--- Test: Stop agent ---')
const result = await ctx.paseo(['stop', agentId])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
console.log('PASS: Agent stopped successfully')
}
async function main(): Promise<void> {
console.log('=== E2E Test: Agent Send Command ===\n')
console.log('This test verifies the `send` command with REAL agents.')
console.log('It may take some time as it waits for agent responses.\n')
try {
await setup()
// Create agent and wait for initial task
const agentId = await test_create_agent()
await test_wait_for_initial_task(agentId)
// Send follow-up message (this waits for completion by default)
await test_agent_send(agentId)
// Verify both messages were processed
await test_verify_logs(agentId)
// Cleanup agent
await test_agent_stop(agentId)
console.log('\n=== All agent-send E2E tests passed! ===')
} catch (err) {
console.error('\n=== E2E test FAILED ===')
console.error(err)
process.exitCode = 1
} finally {
await cleanup()
}
}
main()
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env npx tsx
/**
* E2E Test: Permissions Workflow
*
* This test verifies the permissions workflow using REAL daemons and agents.
* It starts an isolated daemon on a random port and runs actual CLI commands.
*
* Test flow:
* 1. Start daemon on random port
* 2. Create agent in DEFAULT mode (not bypassPermissions) so it requests permissions
* 3. Wait for agent to hit a permission request
* 4. Use `permit ls` to list pending permissions
* 5. Use `permit allow` to approve the permission
* 6. Verify agent continues after approval
* 7. Cleanup
*
* CRITICAL RULES:
* - NEVER use port 6767 (user's running daemon)
* - Always use claude provider with haiku model for fast, cheap tests
* - Clean up resources after test completes
*/
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
}
let ctx: E2EContext
async function setup(): Promise<void> {
console.log('Setting up E2E test context...')
console.log('Starting test daemon on random port (this may take a few seconds)...')
try {
ctx = await createE2ETestContext({ timeout: 45000 })
console.log(`Test daemon started on port ${ctx.port}`)
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
console.log(`Work directory: ${ctx.workDir}`)
} catch (err) {
console.error('Failed to start test daemon:', err)
throw err
}
}
async function cleanup(): Promise<void> {
console.log('\nCleaning up...')
if (ctx) {
await ctx.stop()
console.log('Test daemon stopped and temp directories removed')
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function test_create_agent_with_permissions(): Promise<string> {
console.log('\n--- Test: Create agent in default mode (will request permissions) ---')
// Create agent WITHOUT bypassPermissions - it will need to request permissions
// Use a task that is very likely to trigger a tool use (and thus permission request)
const result = await ctx.paseo(
[
'-q',
'agent',
'run',
'-d',
'--provider',
'claude',
'--model',
'claude-3-5-haiku-20241022',
'--name',
'Permission Test Agent',
// This prompt should trigger file read or bash command, requiring permission
'List the files in the current directory using ls',
],
{ timeout: 60000 }
)
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
const agentId = result.stdout.trim()
assert(agentId.length > 0, 'Should return agent ID')
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
console.log(`PASS: agent created with ID: ${agentId}`)
return agentId
}
async function test_wait_for_permission_request(agentId: string): Promise<void> {
console.log('\n--- Test: Wait for agent to request permission ---')
// Poll for permission requests with timeout
const maxWait = 60000 // 60 seconds max
const pollInterval = 1000 // 1 second
const startTime = Date.now()
while (Date.now() - startTime < maxWait) {
const result = await ctx.paseo(['permit', 'ls', '--format', 'json'])
if (result.exitCode === 0) {
try {
const permissions = JSON.parse(result.stdout.trim())
if (Array.isArray(permissions) && permissions.length > 0) {
// Check if any permission is for our agent
const ourPermission = permissions.find(
(p: { agentId?: string }) => p.agentId?.startsWith(agentId.slice(0, 7)) || agentId.startsWith(p.agentId?.slice(0, 7) || '')
)
if (ourPermission) {
console.log('Permission request detected:', ourPermission)
console.log('PASS: Agent requested permission')
return
}
}
} catch {
// JSON parse failed, continue polling
}
}
await sleep(pollInterval)
}
// If we get here, check agent status - it might have already completed
const statusResult = await ctx.paseo(['inspect', agentId])
console.log('Agent status:', statusResult.stdout)
// It's possible the agent completed without needing permissions (e.g., haiku might not use tools)
// In that case, we should skip the permission tests
if (statusResult.stdout.includes('idle') || statusResult.stdout.includes('completed')) {
console.log('SKIP: Agent completed without requiring permissions (model chose not to use tools)')
throw new Error('SKIP_PERMISSION_TEST')
}
throw new Error('Timeout waiting for agent to request permission')
}
async function test_permit_ls(): Promise<{ agentShortId: string; requestId: string }> {
console.log('\n--- Test: List pending permissions with permit ls ---')
const result = await ctx.paseo(['permit', 'ls', '--format', 'json'])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'permit ls should succeed')
const permissions = JSON.parse(result.stdout.trim())
assert(Array.isArray(permissions), 'Output should be JSON array')
assert(permissions.length > 0, 'Should have at least one pending permission')
const permission = permissions[0]
assert(permission.id, 'Permission should have id')
assert(permission.agentShortId, 'Permission should have agentShortId')
assert(permission.name, 'Permission should have tool name')
console.log(`PASS: Found ${permissions.length} pending permission(s)`)
console.log(` Request ID: ${permission.id}`)
console.log(` Agent: ${permission.agentShortId}`)
console.log(` Tool: ${permission.name}`)
return {
agentShortId: permission.agentShortId,
requestId: permission.id,
}
}
async function test_permit_allow(agentShortId: string, requestId: string): Promise<void> {
console.log('\n--- Test: Allow permission with permit allow ---')
const result = await ctx.paseo(['permit', 'allow', agentShortId, requestId])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'permit allow should succeed')
// Check that output shows the permission was allowed
const output = result.stdout.toLowerCase()
assert(output.includes('allowed') || output.includes(requestId.slice(0, 8).toLowerCase()), 'Should confirm permission was allowed')
console.log('PASS: Permission allowed successfully')
}
async function test_agent_continues(agentId: string): Promise<void> {
console.log('\n--- Test: Verify agent continues after permission granted ---')
// Wait for agent to become idle (should continue after permission)
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed after permission granted')
// Verify agent status
const inspectResult = await ctx.paseo(['inspect', agentId])
console.log('Final agent status:', inspectResult.stdout)
assert(inspectResult.stdout.includes('idle') || inspectResult.stdout.includes('completed'), 'Agent should be idle after completing')
console.log('PASS: Agent completed after permission was granted')
}
async function test_agent_stop(agentId: string): Promise<void> {
console.log('\n--- Test: Stop agent ---')
const result = await ctx.paseo(['stop', agentId])
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
console.log('PASS: Agent stopped successfully')
}
async function main(): Promise<void> {
console.log('=== E2E Test: Permissions Workflow ===\n')
console.log('This test verifies the permission request/allow workflow.')
console.log('It creates an agent that will request tool permissions.\n')
let agentId: string | undefined
try {
await setup()
// Create agent that will need permissions
agentId = await test_create_agent_with_permissions()
try {
// Wait for permission request
await test_wait_for_permission_request(agentId)
// List and verify permissions
const { agentShortId, requestId } = await test_permit_ls()
// Allow the permission
await test_permit_allow(agentShortId, requestId)
// Verify agent continues and completes
await test_agent_continues(agentId)
console.log('\n=== All permissions E2E tests passed! ===')
} catch (err) {
if (err instanceof Error && err.message === 'SKIP_PERMISSION_TEST') {
console.log('\n=== Permissions test skipped (agent completed without tool use) ===')
console.log('This can happen when the model chooses not to use tools.')
// Still considered a pass - the CLI works, just the model behavior varies
} else {
throw err
}
}
} catch (err) {
console.error('\n=== E2E test FAILED ===')
console.error(err)
process.exitCode = 1
} finally {
// Always try to stop the agent if it was created
if (agentId) {
try {
await test_agent_stop(agentId)
} catch {
// Ignore errors during cleanup
}
}
await cleanup()
}
}
main()
+315
View File
@@ -0,0 +1,315 @@
/**
* Test Daemon Helper
*
* Provides utilities for launching real Paseo daemons in E2E tests.
* Each test gets an isolated daemon on a random port with its own PASEO_HOME.
*
* CRITICAL RULES (from design doc):
* 1. Port: Random port in 20000-30000 range - NEVER use 6767 (production)
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
* 4. Model: Always use claude provider with haiku model for fast, cheap tests
* 5. Cleanup: Kill daemon and remove temp dirs after each test
*/
import { mkdtemp, rm, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { ChildProcess, spawn } from 'child_process'
export interface TestDaemonContext {
/** Random port for test daemon (never 6767) */
port: number
/** WebSocket URL for connecting to daemon */
wsUrl: string
/** Temp directory for PASEO_HOME */
paseoHome: string
/** Temp directory for agent working directory */
workDir: string
/** Running daemon process */
process: ChildProcess | null
/** Whether the daemon is ready to accept connections */
isReady: boolean
/** Stop the daemon and clean up resources */
stop: () => Promise<void>
}
/**
* Generate a random port for test daemon
* Uses range 20000-30000 to avoid conflicts
* NEVER uses 6767 (user's running daemon)
*/
export function getRandomPort(): number {
return 20000 + Math.floor(Math.random() * 10000)
}
/**
* Create isolated temp directories for testing
*/
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-e2e-home-'))
const workDir = await mkdtemp(join(tmpdir(), 'paseo-e2e-work-'))
// Create the agents directory that the daemon expects
const agentsDir = join(paseoHome, 'agents')
await mkdir(agentsDir, { recursive: true })
return { paseoHome, workDir }
}
/**
* Wait for daemon to be ready by running `paseo agent ls`
* This connects via WebSocket and ensures the daemon is responsive
*/
async function waitForDaemonReady(
port: number,
timeout = 30000
): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeout) {
try {
const { exitCode } = await runPaseoCli(
{
port,
wsUrl: `ws://127.0.0.1:${port}`,
paseoHome: '',
workDir: '',
process: null,
isReady: false,
stop: async () => {}
},
['agent', 'ls']
)
if (exitCode === 0) {
return // Daemon is ready
}
} catch {
// Connection failed, keep trying
}
await sleep(100)
}
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`)
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Start a test daemon programmatically using the server's bootstrap API
*
* This starts the daemon in a separate process using the CLI's daemon start command
* with isolated PASEO_HOME and PASEO_PORT environment variables.
*/
export async function startTestDaemon(options?: {
port?: number
paseoHome?: string
workDir?: string
timeout?: number
}): Promise<TestDaemonContext> {
const port = options?.port ?? getRandomPort()
const { paseoHome, workDir } = options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs()
const timeout = options?.timeout ?? 30000
const wsUrl = `ws://127.0.0.1:${port}`
// Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, '..', '..')
const cliSrcPath = join(cliDir, 'src', 'index.ts')
// Start daemon process using tsx to run TypeScript directly
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_PORT: String(port),
// Disable relay for tests
PASEO_RELAY_ENABLED: 'false',
// Force no TTY to prevent QR code output
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
})
let stdout = ''
let stderr = ''
daemonProcess.stdout?.on('data', (data) => {
stdout += data.toString()
})
daemonProcess.stderr?.on('data', (data) => {
stderr += data.toString()
})
const cleanup = async () => {
if (daemonProcess && !daemonProcess.killed) {
daemonProcess.kill('SIGTERM')
// Wait for process to exit
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
daemonProcess.kill('SIGKILL')
resolve()
}, 5000)
daemonProcess.on('exit', () => {
clearTimeout(timeoutId)
resolve()
})
})
}
// Clean up temp directories
try {
if (existsSync(paseoHome)) {
await rm(paseoHome, { recursive: true, force: true })
}
} catch {
// Ignore cleanup errors
}
try {
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true })
}
} catch {
// Ignore cleanup errors
}
}
// Handle process errors
daemonProcess.on('error', (err) => {
console.error('Daemon process error:', err)
})
daemonProcess.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Daemon process exited with code ${code}`)
if (stderr) {
console.error('Daemon stderr:', stderr)
}
}
})
const ctx: TestDaemonContext = {
port,
wsUrl,
paseoHome,
workDir,
process: daemonProcess,
isReady: false,
stop: cleanup,
}
// Wait for daemon to be ready
try {
await waitForDaemonReady(port, timeout)
ctx.isReady = true
} catch (err) {
// Daemon failed to start - clean up and rethrow
await cleanup()
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to start test daemon: ${message}\nStdout: ${stdout}\nStderr: ${stderr}`)
}
return ctx
}
/**
* Run a paseo CLI command against a test daemon
*
* This is a helper that sets the correct environment variables
* to point at the test daemon.
*/
export async function runPaseoCli(
ctx: TestDaemonContext,
args: string[],
options?: {
timeout?: number
cwd?: string
}
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const timeout = options?.timeout ?? 60000
const cwd = options?.cwd ?? ctx.workDir
const cliDir = join(import.meta.dirname, '..', '..')
const cliSrcPath = join(cliDir, 'src', 'index.ts')
return new Promise((resolve, reject) => {
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
env: {
...process.env,
PASEO_HOST: `localhost:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
},
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
proc.stdout?.on('data', (data) => {
stdout += data.toString()
})
proc.stderr?.on('data', (data) => {
stderr += data.toString()
})
const timeoutId = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
}, timeout)
proc.on('exit', (code) => {
clearTimeout(timeoutId)
resolve({
exitCode: code ?? 1,
stdout,
stderr,
})
})
proc.on('error', (err) => {
clearTimeout(timeoutId)
reject(err)
})
})
}
/**
* Create a test context that includes a started daemon
* and a helper to run CLI commands against it.
*
* This is the main entry point for E2E tests.
*/
export async function createE2ETestContext(options?: {
timeout?: number
}): Promise<
TestDaemonContext & {
/** Run a paseo CLI command against this daemon */
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
}
> {
const ctx = await startTestDaemon({ timeout: options?.timeout })
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string }) =>
runPaseoCli(ctx, args, opts)
return {
...ctx,
paseo,
}
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env npx zx
/**
* Test runner for Paseo CLI E2E tests
*
* Runs all test phases in sequence and reports results.
* Each test is a separate .ts file that can also be run independently.
*/
import { $ } from 'zx'
import { readdir } from 'fs/promises'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
$.verbose = false
console.log('🧪 Paseo CLI E2E Test Runner\n')
console.log('='.repeat(50))
// Discover all test files
const files = await readdir(__dirname)
const testFiles = files
.filter(f => f.match(/^\d{2}-.*\.test\.ts$/))
.sort()
if (testFiles.length === 0) {
console.log('⚠️ No test files found')
process.exit(0)
}
console.log(`Found ${testFiles.length} test file(s):\n`)
for (const file of testFiles) {
console.log(` - ${file}`)
}
console.log()
let passed = 0
let failed = 0
const failures: { test: string; error: string }[] = []
for (const testFile of testFiles) {
const testPath = join(__dirname, testFile)
const testName = testFile.replace(/\.test\.ts$/, '')
console.log(`\n${'─'.repeat(50)}`)
console.log(`📋 Running ${testName}...`)
console.log('─'.repeat(50))
try {
const result = await $`npx tsx ${testPath}`.nothrow()
if (result.exitCode === 0) {
console.log(`\n✅ ${testName} PASSED`)
passed++
} else {
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`)
if (result.stderr) {
console.log('stderr:', result.stderr)
}
failed++
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` })
}
} catch (e) {
const error = e instanceof Error ? e.message : String(e)
console.log(`\n❌ ${testName} FAILED`)
console.log('Error:', error)
failed++
failures.push({ test: testName, error })
}
}
// Summary
console.log('\n' + '='.repeat(50))
console.log('📊 Test Results')
console.log('='.repeat(50))
console.log(` ✅ Passed: ${passed}`)
console.log(` ❌ Failed: ${failed}`)
console.log(` 📝 Total: ${passed + failed}`)
if (failures.length > 0) {
console.log('\n❌ Failed tests:')
for (const { test, error } of failures) {
console.log(` - ${test}`)
if (error) {
console.log(` ${error.split('\n')[0]}`)
}
}
}
console.log()
process.exit(failed > 0 ? 1 : 0)
+138
View File
@@ -0,0 +1,138 @@
/**
* Test setup utilities for Paseo CLI E2E tests
*
* Critical rules from design doc:
* 1. Port: Random port via 10000 + Math.floor(Math.random() * 50000) - NEVER 6767
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
* 4. Model: Always --provider claude with haiku model for agent tests
* 5. Cleanup: Kill daemon and remove temp dirs after each test
*/
import { $, ProcessPromise, sleep } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
export interface TestContext {
/** Random port for test daemon (never 6767) */
port: number
/** Temp directory for PASEO_HOME */
paseoHome: string
/** Temp directory for agent working directory */
workDir: string
/** Running daemon process */
daemon: ProcessPromise | null
/** Run a paseo CLI command against the test daemon */
paseo: (args: string[]) => ProcessPromise
/** Clean up all resources */
cleanup: () => Promise<void>
}
/**
* Generate a random port for test daemon
* NEVER uses 6767 (user's running daemon)
*/
export function getRandomPort(): number {
return 10000 + Math.floor(Math.random() * 50000)
}
/**
* Create isolated temp directories for testing
*/
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const workDir = await mkdtemp(join(tmpdir(), 'paseo-test-work-'))
return { paseoHome, workDir }
}
/**
* Wait for daemon to be ready by testing WebSocket connection
* Uses `paseo agent ls` which connects via WebSocket
*/
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeout) {
try {
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow()
if (result.exitCode === 0) return
} catch {
// Connection failed, keep trying
}
await sleep(100)
}
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`)
}
/**
* Start an isolated test daemon
*/
export async function startDaemon(
port: number,
paseoHome: string
): Promise<ProcessPromise> {
$.verbose = false
const daemon = $`PASEO_HOME=${paseoHome} PASEO_PORT=${port} paseo daemon start --foreground`.nothrow()
return daemon
}
/**
* Create a full test context with daemon, temp dirs, and helpers
*/
export async function createTestContext(): Promise<TestContext> {
const port = getRandomPort()
const { paseoHome, workDir } = await createTempDirs()
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false
return $`PASEO_HOST=localhost:${port} paseo ${args}`.nothrow()
}
// Cleanup function
const cleanup = async (): Promise<void> => {
if (ctx.daemon) {
ctx.daemon.kill()
}
await rm(paseoHome, { recursive: true, force: true })
await rm(workDir, { recursive: true, force: true })
}
const ctx: TestContext = {
port,
paseoHome,
workDir,
daemon: null,
paseo,
cleanup,
}
return ctx
}
/**
* Create a test context and start the daemon
* Use this for tests that need a running daemon
*/
export async function createTestContextWithDaemon(): Promise<TestContext> {
const ctx = await createTestContext()
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome)
await waitForDaemon(ctx.port)
return ctx
}
/**
* Register cleanup handlers for process exit
*/
export function registerCleanupHandlers(cleanup: () => Promise<void>): void {
const handler = async () => {
await cleanup()
process.exit(0)
}
process.on('exit', () => {
// Can't await in exit handler, but at least try to kill daemon
})
process.on('SIGINT', handler)
process.on('SIGTERM', handler)
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+1
View File
@@ -4,6 +4,7 @@
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/server/exports.ts",
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts" "./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
}, },
"scripts": { "scripts": {
@@ -1,78 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { z } from "zod";
async function main() {
// The bearer token is the base64-encoded credentials
const bearerToken = Buffer.from("mo:bo").toString("base64");
const transport = new StdioClientTransport({
command: "codex",
args: ["mcp-server"],
env: {
...process.env,
// Set the bearer token env var
PASEO_AGENT_CONTROL_TOKEN: bearerToken
},
});
const client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: { elicitation: {} } }
);
// Listen for events
client.setNotificationHandler(
z.object({
method: z.literal("codex/event"),
params: z.object({ msg: z.any() }),
}).passthrough(),
(data) => {
const event = (data.params as { msg: unknown }).msg as { type?: string };
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
}
}
);
await client.connect(transport);
// Use correct route (/mcp/agents) and bearer token env var
process.stdout.write("\n=== Testing HTTP MCP server with correct route and bearer token ===\n\n");
try {
const result = await client.callTool({
name: "codex",
arguments: {
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
sandbox: "danger-full-access",
"approval-policy": "never",
config: {
mcp_servers: {
"agent-control": {
url: "http://localhost:6767/mcp/agents",
bearer_token_env_var: "PASEO_AGENT_CONTROL_TOKEN"
}
}
}
}
}, undefined, { timeout: 60000 });
process.stdout.write("\n=== RESULT ===\n");
const content = (result as { content: { text?: string }[] }).content;
for (const item of content) {
if (item.text) {
process.stdout.write(item.text + "\n");
}
}
} catch (error) {
process.stderr.write("Error: " + String(error) + "\n");
}
await client.close();
}
main().catch((error) => {
process.stderr.write(String(error) + "\n");
process.exitCode = 1;
});
@@ -1,81 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { z } from "zod";
async function main() {
const transport = new StdioClientTransport({
command: "codex",
args: ["mcp-server"],
env: {
...process.env,
// Try passing credentials via env var that Codex might read
AGENT_CONTROL_AUTH: "mo:bo"
},
});
const client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: { elicitation: {} } }
);
// Listen for events
client.setNotificationHandler(
z.object({
method: z.literal("codex/event"),
params: z.object({ msg: z.any() }),
}).passthrough(),
(data) => {
const event = (data.params as { msg: unknown }).msg as { type?: string };
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
}
}
);
await client.connect(transport);
// Try passing MCP server config via the config parameter with headers
process.stdout.write("\n=== Testing HTTP MCP server with basic auth in config ===\n\n");
try {
// Create base64 encoded credentials
const credentials = Buffer.from("mo:bo").toString("base64");
const result = await client.callTool({
name: "codex",
arguments: {
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
sandbox: "danger-full-access",
"approval-policy": "never",
config: {
mcp_servers: {
"agent-control": {
url: "http://localhost:6767/mcp/agent-control",
// Try various ways to pass auth
headers: {
"Authorization": `Basic ${credentials}`
}
}
}
}
}
}, undefined, { timeout: 60000 });
process.stdout.write("\n=== RESULT ===\n");
const content = (result as { content: { text?: string }[] }).content;
for (const item of content) {
if (item.text) {
process.stdout.write(item.text + "\n");
}
}
} catch (error) {
process.stderr.write("Error: " + String(error) + "\n");
}
await client.close();
}
main().catch((error) => {
process.stderr.write(String(error) + "\n");
process.exitCode = 1;
});
@@ -1,78 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { z } from "zod";
async function main() {
// Create base64 encoded credentials
const credentials = Buffer.from("mo:bo").toString("base64");
const transport = new StdioClientTransport({
command: "codex",
args: ["mcp-server"],
env: {
...process.env,
// Set the bearer token env var that we will reference
AGENT_CONTROL_TOKEN: `Basic ${credentials}`
},
});
const client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: { elicitation: {} } }
);
// Listen for events
client.setNotificationHandler(
z.object({
method: z.literal("codex/event"),
params: z.object({ msg: z.any() }),
}).passthrough(),
(data) => {
const event = (data.params as { msg: unknown }).msg as { type?: string };
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
}
}
);
await client.connect(transport);
// Try passing MCP server config with bearer_token_env_var
process.stdout.write("\n=== Testing HTTP MCP server with bearer_token_env_var ===\n\n");
try {
const result = await client.callTool({
name: "codex",
arguments: {
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
sandbox: "danger-full-access",
"approval-policy": "never",
config: {
mcp_servers: {
"agent-control": {
url: "http://localhost:6767/mcp/agent-control",
bearer_token_env_var: "AGENT_CONTROL_TOKEN"
}
}
}
}
}, undefined, { timeout: 60000 });
process.stdout.write("\n=== RESULT ===\n");
const content = (result as { content: { text?: string }[] }).content;
for (const item of content) {
if (item.text) {
process.stdout.write(item.text + "\n");
}
}
} catch (error) {
process.stderr.write("Error: " + String(error) + "\n");
}
await client.close();
}
main().catch((error) => {
process.stderr.write(String(error) + "\n");
process.exitCode = 1;
});
@@ -1,78 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { z } from "zod";
async function main() {
// The basic auth header
const basicAuth = `Basic ${Buffer.from("mo:bo").toString("base64")}`;
const transport = new StdioClientTransport({
command: "codex",
args: ["mcp-server"],
env: {
...process.env,
},
});
const client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: { elicitation: {} } }
);
// Listen for events
client.setNotificationHandler(
z.object({
method: z.literal("codex/event"),
params: z.object({ msg: z.any() }),
}).passthrough(),
(data) => {
const event = (data.params as { msg: unknown }).msg as { type?: string };
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
}
}
);
await client.connect(transport);
// Test using http_headers with Authorization
process.stdout.write("\n=== Testing HTTP MCP server with http_headers and Authorization ===\n\n");
try {
const result = await client.callTool({
name: "codex",
arguments: {
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
sandbox: "danger-full-access",
"approval-policy": "never",
config: {
mcp_servers: {
"agent-control": {
url: "http://localhost:6767/mcp/agents",
http_headers: {
Authorization: basicAuth
}
}
}
}
}
}, undefined, { timeout: 60000 });
process.stdout.write("\n=== RESULT ===\n");
const content = (result as { content: { text?: string }[] }).content;
for (const item of content) {
if (item.text) {
process.stdout.write(item.text + "\n");
}
}
} catch (error) {
process.stderr.write("Error: " + String(error) + "\n");
}
await client.close();
}
main().catch((error) => {
process.stderr.write(String(error) + "\n");
process.exitCode = 1;
});
@@ -1,71 +0,0 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { z } from "zod";
async function main() {
const transport = new StdioClientTransport({
command: "codex",
args: ["mcp-server"],
env: { ...process.env },
});
const client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: { elicitation: {} } }
);
// Listen for events
client.setNotificationHandler(
z.object({
method: z.literal("codex/event"),
params: z.object({ msg: z.any() }),
}).passthrough(),
(data) => {
const event = (data.params as { msg: unknown }).msg as { type?: string };
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
}
}
);
await client.connect(transport);
// Try passing MCP server config via the config parameter with HTTP URL
process.stdout.write("\n=== Testing HTTP MCP server config (agent-control style) ===\n\n");
try {
const result = await client.callTool({
name: "codex",
arguments: {
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
sandbox: "danger-full-access",
"approval-policy": "never",
config: {
mcp_servers: {
"agent-control": {
url: "http://localhost:6767/mcp/agent-control",
type: "http"
}
}
}
}
}, undefined, { timeout: 60000 });
process.stdout.write("\n=== RESULT ===\n");
const content = (result as { content: { text?: string }[] }).content;
for (const item of content) {
if (item.text) {
process.stdout.write(item.text + "\n");
}
}
} catch (error) {
process.stderr.write("Error: " + String(error) + "\n");
}
await client.close();
}
main().catch((error) => {
process.stderr.write(String(error) + "\n");
process.exitCode = 1;
});
@@ -1,27 +0,0 @@
// Quick script to verify buildCodexMcpConfig includes MCP servers
import { buildPaseoDaemonConfigFromEnv } from "../src/server/config.js";
const config = buildPaseoDaemonConfigFromEnv();
process.stdout.write("=== agentControlMcp config ===\n");
process.stdout.write(JSON.stringify(config.agentControlMcp, null, 2) + "\n");
// Simulate what buildCodexMcpConfig does
const mcpServers: Record<string, unknown> = {};
if (config.agentControlMcp) {
const agentControlUrl = config.agentControlMcp.url;
mcpServers["agent-control"] = {
url: agentControlUrl,
...(config.agentControlMcp.headers ? { http_headers: config.agentControlMcp.headers } : {}),
};
}
mcpServers["playwright"] = {
command: "npx",
args: ["@playwright/mcp", "--headless", "--isolated"],
};
process.stdout.write("\n=== Built MCP servers config ===\n");
process.stdout.write(JSON.stringify(mcpServers, null, 2) + "\n");
@@ -163,6 +163,7 @@ export type CreateAgentRequestOptions = {
git?: GitSetupOptions; git?: GitSetupOptions;
worktreeName?: string; worktreeName?: string;
requestId?: string; requestId?: string;
labels?: Record<string, string>;
} & AgentConfigOverrides; } & AgentConfigOverrides;
type VoiceConversationLoadedPayload = VoiceConversationLoadedMessage["payload"]; type VoiceConversationLoadedPayload = VoiceConversationLoadedMessage["payload"];
@@ -656,6 +657,29 @@ export class DaemonClientV2 {
this.sendSessionMessage(message); this.sendSessionMessage(message);
} }
async waitForSessionState(timeout = 5000, requestId?: string): Promise<void> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "request_session_state",
requestId: resolvedRequestId,
});
// First check the existing message queue in case session_state was already received
for (const msg of this.messageQueue) {
if (msg.type === "session_state") {
return;
}
}
// If not in queue, wait for the session_state message
await this.sendSessionMessageOrThrow(message);
return this.waitFor(
(msg) => msg.type === "session_state" ? undefined : null,
timeout,
{ skipQueue: false }
);
}
async loadVoiceConversation( async loadVoiceConversation(
voiceConversationId: string, voiceConversationId: string,
requestId?: string requestId?: string
@@ -751,6 +775,9 @@ export class DaemonClientV2 {
: {}), : {}),
...(options.git ? { git: options.git } : {}), ...(options.git ? { git: options.git } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
...(options.labels && Object.keys(options.labels).length > 0
? { labels: options.labels }
: {}),
}); });
const statusPromise = this.waitFor( const statusPromise = this.waitFor(
@@ -1875,6 +1902,10 @@ export class DaemonClientV2 {
sawRunningInQueue = true; sawRunningInQueue = true;
queuedIdle = null; // Reset: any previous idle was before this run queuedIdle = null; // Reset: any previous idle was before this run
} }
// Return immediately if we have pending permissions (even if still running)
if (sawRunningInQueue && hasPendingPermissions) {
return msg.payload;
}
if ( if (
sawRunningInQueue && sawRunningInQueue &&
(status === "idle" || status === "error") && (status === "idle" || status === "error") &&
@@ -1907,6 +1938,11 @@ export class DaemonClientV2 {
if (status === "running" || hasPendingPermissions) { if (status === "running" || hasPendingPermissions) {
sawRunning = true; sawRunning = true;
} }
// Return if we have pending permissions (even if still running)
// OR if agent is idle/error with no pending permissions (after having run)
if (sawRunning && hasPendingPermissions) {
return msg.payload;
}
if ( if (
sawRunning && sawRunning &&
(status === "idle" || status === "error") && (status === "idle" || status === "error") &&
@@ -2564,6 +2600,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
git: _git, git: _git,
worktreeName: _worktreeName, worktreeName: _worktreeName,
requestId: _requestId, requestId: _requestId,
labels: _labels,
...overrides ...overrides
} = options; } = options;
@@ -24,7 +24,6 @@ import type {
AgentTimelineItem, AgentTimelineItem,
AgentUsage, AgentUsage,
AgentRuntimeInfo, AgentRuntimeInfo,
AgentControlMcpConfig,
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
@@ -58,7 +57,6 @@ export type AgentManagerOptions = {
maxTimelineItems?: number; maxTimelineItems?: number;
idFactory?: () => string; idFactory?: () => string;
registry?: AgentStorage; registry?: AgentStorage;
agentControlMcp?: AgentControlMcpConfig;
onAgentAttention?: AgentAttentionCallback; onAgentAttention?: AgentAttentionCallback;
logger: Logger; logger: Logger;
}; };
@@ -101,11 +99,14 @@ type ManagedAgentBase = {
lastUsage?: AgentUsage; lastUsage?: AgentUsage;
lastError?: string; lastError?: string;
attention: AttentionState; attention: AttentionState;
parentAgentId?: string;
/** /**
* Internal agents are hidden from listings and don't trigger notifications. * Internal agents are hidden from listings and don't trigger notifications.
*/ */
internal?: boolean; internal?: boolean;
/**
* User-defined labels for categorizing agents (e.g., { ui: "true" }).
*/
labels?: Record<string, string>;
}; };
type ManagedAgentWithSession = ManagedAgentBase & { type ManagedAgentWithSession = ManagedAgentBase & {
@@ -189,7 +190,6 @@ export class AgentManager {
private readonly idFactory: () => string; private readonly idFactory: () => string;
private readonly registry?: AgentStorage; private readonly registry?: AgentStorage;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>(); private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly agentControlMcp?: AgentControlMcpConfig;
private onAgentAttention?: AgentAttentionCallback; private onAgentAttention?: AgentAttentionCallback;
private logger: Logger; private logger: Logger;
@@ -198,7 +198,6 @@ export class AgentManager {
options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS; options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS;
this.idFactory = options?.idFactory ?? (() => randomUUID()); this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry; this.registry = options?.registry;
this.agentControlMcp = options?.agentControlMcp;
this.onAgentAttention = options?.onAgentAttention; this.onAgentAttention = options?.onAgentAttention;
this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
if (options?.clients) { if (options?.clients) {
@@ -311,15 +310,17 @@ export class AgentManager {
async createAgent( async createAgent(
config: AgentSessionConfig, config: AgentSessionConfig,
agentId?: string agentId?: string,
options?: { labels?: Record<string, string> }
): Promise<ManagedAgent> { ): Promise<ManagedAgent> {
const normalizedConfig = await this.normalizeConfig(config); const normalizedConfig = await this.normalizeConfig(config, { labels: options?.labels });
const client = this.requireClient(normalizedConfig.provider); const client = this.requireClient(normalizedConfig.provider);
const session = await client.createSession(normalizedConfig); const session = await client.createSession(normalizedConfig);
return this.registerSession( return this.registerSession(
session, session,
normalizedConfig, normalizedConfig,
agentId ?? this.idFactory() agentId ?? this.idFactory(),
{ labels: options?.labels }
); );
} }
@@ -327,7 +328,12 @@ export class AgentManager {
handle: AgentPersistenceHandle, handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
agentId?: string, agentId?: string,
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null } options?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
}
): Promise<ManagedAgent> { ): Promise<ManagedAgent> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>; const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const mergedConfig = { const mergedConfig = {
@@ -346,7 +352,7 @@ export class AgentManager {
session, session,
normalizedConfig, normalizedConfig,
agentId ?? this.idFactory(), agentId ?? this.idFactory(),
timestamps options
); );
} }
@@ -378,7 +384,8 @@ export class AgentManager {
); );
} }
return this.registerSession(session, overrides, agentId); // Preserve existing labels during refresh
return this.registerSession(session, overrides, agentId, { labels: existing.labels });
} }
async closeAgent(agentId: string): Promise<void> { async closeAgent(agentId: string): Promise<void> {
@@ -795,15 +802,17 @@ export class AgentManager {
session: AgentSession, session: AgentSession,
config: AgentSessionConfig, config: AgentSessionConfig,
agentId: string, agentId: string,
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null } options?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
}
): Promise<ManagedAgent> { ): Promise<ManagedAgent> {
if (this.agents.has(agentId)) { if (this.agents.has(agentId)) {
throw new Error(`Agent with id ${agentId} already exists`); throw new Error(`Agent with id ${agentId} already exists`);
} }
// Inform the session of its managed agent ID for MCP parent-child relationships
session.setManagedAgentId?.(agentId);
const now = new Date(); const now = new Date();
const managed = { const managed = {
id: agentId, id: agentId,
@@ -814,8 +823,8 @@ export class AgentManager {
config, config,
runtimeInfo: undefined, runtimeInfo: undefined,
lifecycle: "initializing", lifecycle: "initializing",
createdAt: timestamps?.createdAt ?? now, createdAt: options?.createdAt ?? now,
updatedAt: timestamps?.updatedAt ?? now, updatedAt: options?.updatedAt ?? now,
availableModes: [], availableModes: [],
currentModeId: null, currentModeId: null,
pendingPermissions: new Map(), pendingPermissions: new Map(),
@@ -823,10 +832,10 @@ export class AgentManager {
timeline: [], timeline: [],
persistence: session.describePersistence(), persistence: session.describePersistence(),
historyPrimed: false, historyPrimed: false,
lastUserMessageAt: timestamps?.lastUserMessageAt ?? null, lastUserMessageAt: options?.lastUserMessageAt ?? null,
attention: { requiresAttention: false }, attention: { requiresAttention: false },
parentAgentId: config.parentAgentId,
internal: config.internal ?? false, internal: config.internal ?? false,
labels: options?.labels,
} as ActiveManagedAgent; } as ActiveManagedAgent;
this.agents.set(agentId, managed); this.agents.set(agentId, managed);
@@ -1093,7 +1102,8 @@ export class AgentManager {
private async normalizeConfig( private async normalizeConfig(
config: AgentSessionConfig config: AgentSessionConfig,
options?: { labels?: Record<string, string> }
): Promise<AgentSessionConfig> { ): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...config }; const normalized: AgentSessionConfig = { ...config };
@@ -1107,14 +1117,9 @@ export class AgentManager {
normalized.model = trimmed.length > 0 ? trimmed : undefined; normalized.model = trimmed.length > 0 ? trimmed : undefined;
} }
if (!normalized.agentControlMcp && this.agentControlMcp) { // Inject paseoPromptInstructions for UI agents (with ui=true label)
normalized.agentControlMcp = this.agentControlMcp; const isUiAgent = options?.labels?.ui === "true";
} if (isUiAgent) {
if (
normalized.paseoPromptInstructions === undefined &&
normalized.agentControlMcp
) {
normalized.paseoPromptInstructions = getSelfIdentificationInstructions({ normalized.paseoPromptInstructions = getSelfIdentificationInstructions({
cwd: normalized.cwd, cwd: normalized.cwd,
}); });
@@ -102,9 +102,6 @@ describe("agent MCP end-to-end", () => {
mcpDebug: false, mcpDebug: false,
agentClients: {}, agentClients: {},
agentStoragePath: path.join(paseoHome, "agents"), agentStoragePath: path.join(paseoHome, "agents"),
agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`,
},
}; };
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR; const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
@@ -235,9 +232,6 @@ describe("agent MCP end-to-end", () => {
mcpDebug: false, mcpDebug: false,
agentClients: {}, agentClients: {},
agentStoragePath: path.join(paseoHome, "agents"), agentStoragePath: path.join(paseoHome, "agents"),
agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`,
},
}; };
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR; const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
@@ -44,6 +44,7 @@ export function toStoredAgentRecord(
? agent.lastUserMessageAt.toISOString() ? agent.lastUserMessageAt.toISOString()
: null, : null,
title: options?.title ?? null, title: options?.title ?? null,
labels: agent.labels && Object.keys(agent.labels).length > 0 ? agent.labels : undefined,
lastStatus: agent.lifecycle, lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null, lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null, config: config ?? null,
@@ -56,7 +57,6 @@ export function toStoredAgentRecord(
attentionTimestamp: agent.attention.requiresAttention attentionTimestamp: agent.attention.requiresAttention
? agent.attention.attentionTimestamp.toISOString() ? agent.attention.attentionTimestamp.toISOString()
: null, : null,
parentAgentId: agent.parentAgentId ?? null,
internal: options?.internal, internal: options?.internal,
} satisfies StoredAgentRecord; } satisfies StoredAgentRecord;
} }
@@ -85,7 +85,7 @@ export function toAgentPayload(
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions), pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: sanitizePersistenceHandle(agent.persistence), persistence: sanitizePersistenceHandle(agent.persistence),
title: options?.title ?? null, title: options?.title ?? null,
parentAgentId: agent.parentAgentId ?? null, labels: agent.labels && Object.keys(agent.labels).length > 0 ? agent.labels : undefined,
}; };
const usage = sanitizeUsage(agent.lastUsage); const usage = sanitizeUsage(agent.lastUsage);
@@ -154,7 +154,6 @@ describe("getStructuredAgentResponse (e2e)", () => {
cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-")); cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-"));
manager = new AgentManager({ manager = new AgentManager({
clients: createAllClients(logger), clients: createAllClients(logger),
agentControlMcp: { url: agentMcpServer.url },
logger, logger,
}); });
}); });
@@ -171,11 +171,6 @@ export type AgentCommandResult = {
usage?: AgentUsage; usage?: AgentUsage;
}; };
export type AgentControlMcpConfig = {
url: string;
headers?: Record<string, string>;
};
export type ListPersistedAgentsOptions = { export type ListPersistedAgentsOptions = {
limit?: number; limit?: number;
}; };
@@ -201,7 +196,6 @@ export type AgentSessionConfig = {
networkAccess?: boolean; networkAccess?: boolean;
webSearch?: boolean; webSearch?: boolean;
reasoningEffort?: string; reasoningEffort?: string;
agentControlMcp?: AgentControlMcpConfig;
/** /**
* Paseo-owned instructions injected into the first user prompt via * Paseo-owned instructions injected into the first user prompt via
* <paseo-instructions>...</paseo-instructions>. * <paseo-instructions>...</paseo-instructions>.
@@ -215,7 +209,6 @@ export type AgentSessionConfig = {
claude?: Partial<ClaudeAgentOptions>; claude?: Partial<ClaudeAgentOptions>;
}; };
mcpServers?: AgentMetadata; mcpServers?: AgentMetadata;
parentAgentId?: string;
/** /**
* Internal agents are hidden from listings and don't trigger notifications. * Internal agents are hidden from listings and don't trigger notifications.
* They are used for ephemeral system tasks like commit/PR generation. * They are used for ephemeral system tasks like commit/PR generation.
@@ -239,12 +232,6 @@ export interface AgentSession {
describePersistence(): AgentPersistenceHandle | null; describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>; interrupt(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
/**
* Set the managed agent ID for this session. This is called by AgentManager
* after registration to allow the session to include its ID in MCP requests
* (for parent-child agent relationships).
*/
setManagedAgentId?(agentId: string): void;
/** /**
* List available slash commands for this session. * List available slash commands for this session.
* Commands are provider-specific - Claude supports skills and built-in commands. * Commands are provider-specific - Claude supports skills and built-in commands.
@@ -37,6 +37,7 @@ const STORED_AGENT_SCHEMA = z.object({
lastActivityAt: z.string().optional(), lastActivityAt: z.string().optional(),
lastUserMessageAt: z.string().nullable().optional(), lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(), title: z.string().nullable().optional(),
labels: z.record(z.string()).optional(),
lastStatus: AgentStatusSchema.default("closed"), lastStatus: AgentStatusSchema.default("closed"),
lastModeId: z.string().nullable().optional(), lastModeId: z.string().nullable().optional(),
config: SERIALIZABLE_CONFIG_SCHEMA, config: SERIALIZABLE_CONFIG_SCHEMA,
@@ -53,7 +54,6 @@ const STORED_AGENT_SCHEMA = z.object({
requiresAttention: z.boolean().optional(), requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(), attentionTimestamp: z.string().nullable().optional(),
parentAgentId: z.string().nullable().optional(),
internal: z.boolean().optional(), internal: z.boolean().optional(),
archivedAt: z.string().nullable().optional(), archivedAt: z.string().nullable().optional(),
}); });
+1 -14
View File
@@ -40,7 +40,7 @@ export interface AgentMcpServerOptions {
paseoHome?: string; paseoHome?: string;
/** /**
* ID of the agent that is connecting to this MCP server. * ID of the agent that is connecting to this MCP server.
* When set, create_agent will auto-inject this as parentAgentId. * Used for cwd/mode inheritance when agents spawn child agents.
*/ */
callerAgentId?: string; callerAgentId?: string;
logger: Logger; logger: Logger;
@@ -350,12 +350,6 @@ export async function createAgentMcpServer(
.describe( .describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately." "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
), ),
parentAgentId: z
.string()
.optional()
.describe(
"Optional parent agent ID. When set, this agent is a child of the specified parent agent."
),
}; };
const createAgentInputSchema = callerAgentId const createAgentInputSchema = callerAgentId
@@ -400,12 +394,10 @@ export async function createAgentMcpServer(
worktreeName?: string; worktreeName?: string;
background?: boolean; background?: boolean;
title: string; title: string;
parentAgentId?: string;
}; };
let resolvedCwd: string; let resolvedCwd: string;
let resolvedMode: string | undefined; let resolvedMode: string | undefined;
let resolvedParentAgentId: string | undefined;
if (callerAgentId) { if (callerAgentId) {
const parentAgent = agentManager.getAgent(callerAgentId); const parentAgent = agentManager.getAgent(callerAgentId);
@@ -413,7 +405,6 @@ export async function createAgentMcpServer(
throw new Error(`Parent agent ${callerAgentId} not found`); throw new Error(`Parent agent ${callerAgentId} not found`);
} }
resolvedCwd = parentAgent.cwd; resolvedCwd = parentAgent.cwd;
resolvedParentAgentId = callerAgentId;
const provider: AgentProvider = agentType ?? "claude"; const provider: AgentProvider = agentType ?? "claude";
const parentMode = parentAgent.currentModeId; const parentMode = parentAgent.currentModeId;
@@ -430,14 +421,12 @@ export async function createAgentMcpServer(
initialMode: string; initialMode: string;
worktreeName?: string; worktreeName?: string;
baseBranch?: string; baseBranch?: string;
parentAgentId?: string;
}; };
const { const {
cwd, cwd,
initialMode, initialMode,
worktreeName, worktreeName,
baseBranch, baseBranch,
parentAgentId,
} = topLevelArgs; } = topLevelArgs;
resolvedCwd = expandPath(cwd); resolvedCwd = expandPath(cwd);
@@ -457,7 +446,6 @@ export async function createAgentMcpServer(
} }
resolvedMode = initialMode; resolvedMode = initialMode;
resolvedParentAgentId = parentAgentId;
} }
const provider: AgentProvider = agentType ?? "claude"; const provider: AgentProvider = agentType ?? "claude";
@@ -467,7 +455,6 @@ export async function createAgentMcpServer(
cwd: resolvedCwd, cwd: resolvedCwd,
modeId: resolvedMode, modeId: resolvedMode,
title: normalizedTitle ?? undefined, title: normalizedTitle ?? undefined,
parentAgentId: resolvedParentAgentId,
}); });
if (initialPrompt) { if (initialPrompt) {
@@ -14,7 +14,7 @@ Core rules:
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself. - In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion"). - Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
- Set cwd to the repository root and choose the most permissive mode available. - Set cwd to the repository root and choose the most permissive mode available.
- If an agent control call fails, list agents before launching another; it may just be a wait timeout. - If an agent control tool call fails, list agents before launching another; it may just be a wait timeout.
Context management: Context management:
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area). - Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
@@ -26,9 +26,6 @@ const hasClaudeCredentials =
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
modeId: "plan", modeId: "plan",
agentControlMcp: {
url: "http://localhost:6767/mcp", // Placeholder - not actually used in plan mode
},
}; };
beforeAll(async () => { beforeAll(async () => {
@@ -240,7 +240,6 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
provider: "claude", provider: "claude",
cwd, cwd,
modeId: options?.modeId, modeId: options?.modeId,
agentControlMcp: { url: agentMcpServer.url },
extra: { extra: {
claude: { claude: {
sandbox: { enabled: true, autoAllowBashIfSandboxed: false }, sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
@@ -116,16 +116,6 @@ type ClaudeAgentSessionOptions = {
logger: Logger; logger: Logger;
}; };
function appendCallerAgentId(url: string, agentId: string): string {
try {
const parsed = new URL(url);
parsed.searchParams.set("callerAgentId", agentId);
return parsed.toString();
} catch {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}callerAgentId=${encodeURIComponent(agentId)}`;
}
}
export function extractUserMessageText(content: unknown): string | null { export function extractUserMessageText(content: unknown): string | null {
if (typeof content === "string") { if (typeof content === "string") {
@@ -228,25 +218,6 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
if (typeof metadata.reasoningEffort === "string") { if (typeof metadata.reasoningEffort === "string") {
result.reasoningEffort = metadata.reasoningEffort; result.reasoningEffort = metadata.reasoningEffort;
} }
if (isMetadata(metadata.agentControlMcp)) {
const url = metadata.agentControlMcp.url;
const headers = metadata.agentControlMcp.headers;
if (typeof url === "string") {
const agentControlMcp: AgentSessionConfig["agentControlMcp"] = { url };
if (isMetadata(headers)) {
const normalizedHeaders: { [key: string]: string } = {};
for (const [key, value] of Object.entries(headers)) {
if (typeof value === "string") {
normalizedHeaders[key] = value;
}
}
if (Object.keys(normalizedHeaders).length > 0) {
agentControlMcp.headers = normalizedHeaders;
}
}
result.agentControlMcp = agentControlMcp;
}
}
if (isMetadata(metadata.extra)) { if (isMetadata(metadata.extra)) {
const extra: AgentSessionConfig["extra"] = {}; const extra: AgentSessionConfig["extra"] = {};
if (isMetadata(metadata.extra.codex)) { if (isMetadata(metadata.extra.codex)) {
@@ -262,9 +233,6 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
if (isMetadata(metadata.mcpServers)) { if (isMetadata(metadata.mcpServers)) {
result.mcpServers = metadata.mcpServers; result.mcpServers = metadata.mcpServers;
} }
if (typeof metadata.parentAgentId === "string") {
result.parentAgentId = metadata.parentAgentId;
}
return result; return result;
} }
@@ -432,7 +400,6 @@ class ClaudeAgentSession implements AgentSession {
private activeTurnPromise: Promise<void> | null = null; private activeTurnPromise: Promise<void> | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null; private lastOptionsModel: string | null = null;
private managedAgentId: string | null = null;
constructor( constructor(
config: ClaudeAgentConfig, config: ClaudeAgentConfig,
@@ -722,10 +689,6 @@ class ClaudeAgentSession implements AgentSession {
this.input = null; this.input = null;
} }
setManagedAgentId(agentId: string): void {
this.managedAgentId = agentId;
}
async listCommands(): Promise<AgentSlashCommand[]> { async listCommands(): Promise<AgentSlashCommand[]> {
const q = await this.ensureQuery(); const q = await this.ensureQuery();
const commands = await q.supportedCommands(); const commands = await q.supportedCommands();
@@ -819,28 +782,11 @@ class ClaudeAgentSession implements AgentSession {
...this.config.extra?.claude, ...this.config.extra?.claude,
}; };
// Always include the agent-control MCP server so agents can launch other agents
if (!this.config.agentControlMcp) {
throw new Error("agentControlMcp is required for ClaudeAgentSession");
}
const agentControlConfig = this.config.agentControlMcp;
const agentControlUrl = this.managedAgentId
? appendCallerAgentId(agentControlConfig.url, this.managedAgentId)
: agentControlConfig.url;
const defaultMcpServers: Record<string, ClaudeMcpServerConfig> = {
"agent-control": {
type: "http",
url: agentControlUrl,
...(agentControlConfig.headers ? { headers: agentControlConfig.headers } : {}),
},
};
if (this.config.mcpServers) { if (this.config.mcpServers) {
const normalizedUserServers = this.normalizeMcpServers(this.config.mcpServers); const normalizedUserServers = this.normalizeMcpServers(this.config.mcpServers);
// Merge user-provided MCP servers with defaults, user servers take precedence if (normalizedUserServers) {
base.mcpServers = { ...defaultMcpServers, ...normalizedUserServers }; base.mcpServers = normalizedUserServers;
} else { }
base.mcpServers = defaultMcpServers;
} }
if (this.config.model) { if (this.config.model) {
@@ -1859,11 +1859,6 @@ const PermissionParamsSchema = z
type PermissionParams = z.infer<typeof PermissionParamsSchema>; type PermissionParams = z.infer<typeof PermissionParamsSchema>;
const AgentControlMcpConfigSchema = z.object({
url: z.string(),
headers: z.record(z.string()).optional(),
});
type AgentSessionExtra = NonNullable<AgentSessionConfig["extra"]>; type AgentSessionExtra = NonNullable<AgentSessionConfig["extra"]>;
const AgentSessionExtraSchema = z.object({ const AgentSessionExtraSchema = z.object({
@@ -1883,10 +1878,8 @@ const AgentSessionConfigSchema = z
networkAccess: z.boolean().optional(), networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(), webSearch: z.boolean().optional(),
reasoningEffort: z.string().optional(), reasoningEffort: z.string().optional(),
agentControlMcp: AgentControlMcpConfigSchema.optional(),
extra: AgentSessionExtraSchema.optional(), extra: AgentSessionExtraSchema.optional(),
mcpServers: z.record(z.unknown()).optional(), mcpServers: z.record(z.unknown()).optional(),
parentAgentId: z.string().optional(),
}) })
.passthrough(); .passthrough();
@@ -2718,7 +2711,6 @@ function buildCodexMcpConfig(
config: AgentSessionConfig, config: AgentSessionConfig,
prompt: string, prompt: string,
modeId: string, modeId: string,
managedAgentId?: string,
experimentalResume?: string | null experimentalResume?: string | null
): { ): {
prompt: string; prompt: string;
@@ -2762,21 +2754,6 @@ function buildCodexMcpConfig(
// Build MCP servers configuration // Build MCP servers configuration
const mcpServers: Record<string, CodexMcpServerConfig> = {}; const mcpServers: Record<string, CodexMcpServerConfig> = {};
// Add agent-control MCP server (HTTP-based) if configured
if (config.agentControlMcp) {
let agentControlUrl = config.agentControlMcp.url;
// Append caller agent ID to URL if this is a managed agent
if (managedAgentId) {
const separator = agentControlUrl.includes("?") ? "&" : "?";
agentControlUrl = `${agentControlUrl}${separator}callerAgentId=${encodeURIComponent(managedAgentId)}`;
}
mcpServers["agent-control"] = {
url: agentControlUrl,
tool_timeout_sec: 600, // 10 min timeout for child agents
...(config.agentControlMcp.headers ? { http_headers: config.agentControlMcp.headers } : {}),
};
}
// Merge MCP servers from extra.codex.mcp_servers (legacy location) // Merge MCP servers from extra.codex.mcp_servers (legacy location)
const extraCodex = config.extra?.codex as Record<string, unknown> | undefined; const extraCodex = config.extra?.codex as Record<string, unknown> | undefined;
if (extraCodex?.mcp_servers && typeof extraCodex.mcp_servers === "object") { if (extraCodex?.mcp_servers && typeof extraCodex.mcp_servers === "object") {
@@ -3037,7 +3014,6 @@ class CodexMcpAgentSession implements AgentSession {
private turnState: TurnState | null = null; private turnState: TurnState | null = null;
private pendingPatchChanges = new Map<string, PatchFileChange[]>(); private pendingPatchChanges = new Map<string, PatchFileChange[]>();
private patchChangesByCallId = new Map<string, PatchFileChange[]>(); private patchChangesByCallId = new Map<string, PatchFileChange[]>();
private managedAgentId: string | null = null;
private resumeHandle: AgentPersistenceHandle | null = null; private resumeHandle: AgentPersistenceHandle | null = null;
private pendingResumeFile: string | null = null; private pendingResumeFile: string | null = null;
@@ -3541,10 +3517,6 @@ class CodexMcpAgentSession implements AgentSession {
this.conversationId = null; this.conversationId = null;
} }
setManagedAgentId(agentId: string): void {
this.managedAgentId = agentId;
}
async listCommands(): Promise<AgentSlashCommand[]> { async listCommands(): Promise<AgentSlashCommand[]> {
const [skills, prompts] = await Promise.all([ const [skills, prompts] = await Promise.all([
listCodexSkills(this.config.cwd), listCodexSkills(this.config.cwd),
@@ -3591,7 +3563,6 @@ class CodexMcpAgentSession implements AgentSession {
this.config, this.config,
prompt, prompt,
this.currentMode, this.currentMode,
this.managedAgentId ?? undefined,
resumeFile resumeFile
); );
const attempt = async (arguments_: CodexToolArguments) => const attempt = async (arguments_: CodexToolArguments) =>
@@ -3631,7 +3602,7 @@ class CodexMcpAgentSession implements AgentSession {
); );
if (isMissingConversationIdResponse(response)) { if (isMissingConversationIdResponse(response)) {
const replayPrompt = this.buildResumePrompt(prompt); const replayPrompt = this.buildResumePrompt(prompt);
const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode, this.managedAgentId ?? undefined); const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode);
const attempt = async (arguments_: CodexToolArguments) => const attempt = async (arguments_: CodexToolArguments) =>
this.client.callTool( this.client.callTool(
{ name: "codex", arguments: arguments_ }, { name: "codex", arguments: arguments_ },
@@ -3654,7 +3625,7 @@ class CodexMcpAgentSession implements AgentSession {
} catch (error) { } catch (error) {
if (isMissingConversationIdError(error)) { if (isMissingConversationIdError(error)) {
const replayPrompt = this.buildResumePrompt(prompt); const replayPrompt = this.buildResumePrompt(prompt);
const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode, this.managedAgentId ?? undefined); const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode);
const attempt = async (arguments_: CodexToolArguments) => const attempt = async (arguments_: CodexToolArguments) =>
this.client.callTool( this.client.callTool(
{ name: "codex", arguments: arguments_ }, { name: "codex", arguments: arguments_ },
+1 -7
View File
@@ -56,7 +56,6 @@ import { printPairingQrIfEnabled } from "./pairing-qr.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import type { import type {
AgentClient, AgentClient,
AgentControlMcpConfig,
AgentProvider, AgentProvider,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
@@ -78,7 +77,6 @@ export type PaseoDaemonConfig = {
mcpDebug: boolean; mcpDebug: boolean;
agentClients: Partial<Record<AgentProvider, AgentClient>>; agentClients: Partial<Record<AgentProvider, AgentClient>>;
agentStoragePath: string; agentStoragePath: string;
agentControlMcp: AgentControlMcpConfig;
relayEnabled?: boolean; relayEnabled?: boolean;
relayEndpoint?: string; relayEndpoint?: string;
appBaseUrl?: string; appBaseUrl?: string;
@@ -211,7 +209,6 @@ export async function createPaseoDaemon(
...config.agentClients, ...config.agentClients,
}, },
registry: agentStorage, registry: agentStorage,
agentControlMcp: config.agentControlMcp,
logger, logger,
}); });
@@ -378,10 +375,7 @@ export async function createPaseoDaemon(
agentStorage, agentStorage,
downloadTokenStore, downloadTokenStore,
config.paseoHome, config.paseoHome,
{ agentMcpRoute,
agentMcpUrl: config.agentControlMcp.url,
agentMcpHeaders: config.agentControlMcp.headers,
},
{ allowedOrigins }, { allowedOrigins },
{ stt: sttService, tts: ttsService }, { stt: sttService, tts: ttsService },
terminalManager terminalManager
-3
View File
@@ -80,9 +80,6 @@ export function loadConfig(
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE, agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`], agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
mcpDebug: env.MCP_DEBUG === "1", mcpDebug: env.MCP_DEBUG === "1",
agentControlMcp: {
url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
},
agentStoragePath: path.join(paseoHome, "agents"), agentStoragePath: path.join(paseoHome, "agents"),
staticDir: "public", staticDir: "public",
agentClients: {}, agentClients: {},
@@ -57,9 +57,6 @@ async function startDaemon(options: {
mcpDebug: false, mcpDebug: false,
agentClients: {}, agentClients: {},
agentStoragePath: path.join(options.paseoHome, "agents"), agentStoragePath: path.join(options.paseoHome, "agents"),
agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`,
},
openai: process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : undefined, openai: process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : undefined,
}; };
@@ -1,22 +1,16 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs"; import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import path from "path"; import path from "path";
import { import {
createDaemonTestContext, createDaemonTestContext,
type DaemonTestContext, type DaemonTestContext,
} from "../test-utils/index.js"; } from "../test-utils/index.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string { function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
} }
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -29,140 +23,10 @@ describe("daemon E2E", () => {
}, 60000); }, 60000);
describe("multi-agent orchestration", () => { describe("multi-agent orchestration", () => {
test( // TODO: Re-implement orchestration tests with new Paseo MCP
"parent agent creates child agent via agent-control MCP", // The old agent-control MCP has been removed
async () => { test("placeholder for future orchestration tests", async () => {
const cwd = tmpCwd(); expect(true).toBe(true);
const childCwd = tmpCwd(); });
// Create parent Codex agent
const parent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd,
title: "Parent Agent",
});
expect(parent.id).toBeTruthy();
expect(parent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Prompt the parent to create a child agent using agent-control MCP
const prompt = [
`Use the create_agent tool from the agent-control MCP server to create a new codex agent.`,
`Set the cwd to: ${childCwd}`,
`Set the title to: Child Agent`,
`Set agentType to: codex`,
`Do NOT set an initialPrompt - just create the agent.`,
`After creating the agent, reply with "CREATED" followed by the child's agentId.`,
].join(" ");
await ctx.client.sendMessage(parent.id, prompt);
// Wait for parent to complete
const afterCreate = await ctx.client.waitForAgentIdle(
parent.id,
120000
);
expect(afterCreate.status).toBe("idle");
// Verify timeline contains a tool call to create_agent
const queue = ctx.client.getMessageQueue();
const timelineItems: AgentTimelineItem[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === parent.id &&
m.payload.event.type === "timeline"
) {
timelineItems.push(m.payload.event.item);
}
}
// Should have a tool call to create_agent from agent-control
const hasCreateAgentCall = timelineItems.some(
(item) =>
item.type === "tool_call" &&
item.name === "agent-control.create_agent"
);
expect(hasCreateAgentCall).toBe(true);
// Now verify we can see both agents via session_state
// Send a list_persisted_agents_request to trigger session_state refresh
// Or we can check the queue for agent_state messages
const agentStateMessages = queue.filter(
(m) => m.type === "agent_state"
);
// Extract unique agent IDs from state messages
const agentIds = new Set<string>();
for (const m of agentStateMessages) {
if (m.type === "agent_state") {
agentIds.add(m.payload.id);
}
}
// Should have at least 2 agents (parent + child)
expect(agentIds.size).toBeGreaterThanOrEqual(2);
expect(agentIds.has(parent.id)).toBe(true);
// Get the child agent ID from the tool call output
const createAgentCall = timelineItems.find(
(item) =>
item.type === "tool_call" &&
item.name === "agent-control.create_agent"
);
let childAgentId: string | null = null;
if (
createAgentCall &&
createAgentCall.type === "tool_call" &&
createAgentCall.output
) {
const output = createAgentCall.output as unknown;
const tryExtract = (value: unknown): string | null => {
if (!value) return null;
if (typeof value === "string") {
try {
return tryExtract(JSON.parse(value));
} catch {
return null;
}
}
if (typeof value !== "object") return null;
const asObj = value as Record<string, unknown>;
const direct = asObj.agentId;
if (typeof direct === "string") return direct;
const structured = asObj.structuredContent;
if (structured && typeof structured === "object") {
const nested = (structured as Record<string, unknown>).agentId;
if (typeof nested === "string") return nested;
}
if (typeof structured === "string") {
try {
return tryExtract(JSON.parse(structured));
} catch {
return null;
}
}
return null;
};
childAgentId = tryExtract(output);
}
// Verify we found the child agent ID
expect(childAgentId).toBeTruthy();
expect(agentIds.has(childAgentId!)).toBe(true);
// Cleanup
rmSync(cwd, { recursive: true, force: true });
rmSync(childCwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for multi-agent E2E
);
}); });
}); });
@@ -0,0 +1,123 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "wait-perm-e2e-"));
}
/**
* Tests for wait returning on permission request.
*
* The `paseo wait` command should return when:
* 1. Agent completes (goes idle)
* 2. Agent requests permission
*
* This test verifies that waitForAgentIdle correctly returns when
* an agent requests permission, not just when it goes idle.
*/
describe("wait returns on permission request", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("Claude provider", () => {
test(
"waitForAgentIdle returns when permission is requested (not just when idle)",
async () => {
const cwd = tmpCwd();
const testFilePath = path.join(cwd, "test-wait-perm.txt");
// Create Claude agent with default mode (always ask for permissions)
const agent = await ctx.client.createAgent({
provider: "claude",
model: "haiku",
cwd,
title: "Wait Permission Test",
modeId: "default",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Clear message queue before sending prompt
ctx.client.clearMessageQueue();
// Send a prompt that requires file write permission
const prompt = [
`You must use the Write tool to create a file at "${testFilePath}" with content "hello".`,
"Do not respond before attempting to write the file.",
].join(" ");
await ctx.client.sendMessage(agent.id, prompt);
// CRITICAL: This is the behavior we're testing
// waitForAgentIdle should return when permission is requested,
// NOT wait until the agent is fully idle (which would timeout or
// require us to approve the permission first)
const startTime = Date.now();
const state = await ctx.client.waitForAgentIdle(agent.id, 60000);
const waitDuration = Date.now() - startTime;
// If wait returns because of permission request, we should have:
// 1. Agent status still "running" (not idle yet - waiting for permission)
// 2. Pending permissions in the state
// 3. Wait should return quickly (under 30 seconds, not timeout)
// Check that we have a pending permission
const hasPendingPermission =
(state.pendingPermissions && state.pendingPermissions.length > 0);
// Log for debugging
console.log("Wait returned after", waitDuration, "ms");
console.log("Agent status:", state.status);
console.log("Pending permissions:", state.pendingPermissions?.length ?? 0);
// THE ASSERTION:
// If waitForAgentIdle correctly yields on permission request,
// we should either:
// - Get a state with pending permissions (status might be "running")
// - Or the state should be from right when permission was requested
//
// If waitForAgentIdle does NOT yield on permission request,
// this test will either:
// - Timeout (60s)
// - Return only after we never approve permission and agent errors/gives up
// This test will FAIL if waitForAgentIdle waits for full idle
// instead of returning on permission request
expect(hasPendingPermission).toBe(true);
// Also verify we can get the permission via waitForPermission
// (This should return immediately since permission is already pending)
const permission = await ctx.client.waitForPermission(agent.id, 5000);
expect(permission).toBeTruthy();
expect(permission.kind).toBe("tool");
// Clean up: deny the permission so agent can finish
await ctx.client.respondToPermission(agent.id, permission.id, {
behavior: "deny",
message: "Test complete",
});
// Wait for agent to finish processing the denial
await ctx.client.waitForAgentIdle(agent.id, 30000);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
120000
);
});
});
+27
View File
@@ -0,0 +1,27 @@
// CLI exports for @paseo/server
export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
export { loadConfig } from "./config.js";
export { resolvePaseoHome } from "./paseo-home.js";
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { DaemonClientV2, type DaemonClientV2Config, type ConnectionState, type DaemonEvent } from "../client/daemon-client-v2.js";
// Agent SDK types for CLI commands
export type {
AgentMode,
AgentUsage,
AgentCapabilityFlags,
AgentPermissionRequest,
AgentTimelineItem,
} from "./agent/agent-sdk-types.js";
// Agent activity curator for CLI logs
export { curateAgentActivity } from "./agent/activity-curator.js";
// WebSocket message types for CLI streaming
export type {
AgentSnapshotPayload,
AgentStreamEventPayload,
AgentStreamMessage,
AgentStreamSnapshotMessage,
} from "../shared/messages.js";

Some files were not shown because too many files have changed in this diff Show More