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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).

## 1.0.31 (2026-07-27)

### New Features

- **First-class Shell agents** — create and edit command-backed agents from the CLI, editor, and TUI. Shell agents run their configured command from the task workspace and map exit code `0` to success and non-zero exits to failure.
- **Simplified Shell setup** — selecting Shell asks only for the agent name and command, hides model, reasoning, role, skills, and team fields, and applies the automatic approval policy.
- **Command-aware TUI** — creation confirmations and agent details show the configured command, while Shell agent details prioritize the active task and command over AI-only model information.

### Bug Fixes

- **Reliable targeted run completion** — `orch run <task-id>` keeps its event listener until the requested run completes, preserving final output and returning a non-zero process exit code when the Shell command fails.
- **Immediate wizard confirmation** — text and textarea validation is checked against the current value when the user confirms, eliminating stale debounce errors without allowing invalid values through.
- **Safe adapter transitions** — switching an existing agent to Shell clears incompatible model and effort settings, requires a command, and defaults approval to automatic.

### Refactoring

- Centralized repeated Shell conditions and agent-creation status formatting without changing behavior.

### Tests

- Added service, CLI, adapter, wizard, command-bar, and TUI regression coverage for Shell creation, editing, validation, execution, success, and failure.
- Verified the built CLI in a real pseudo-terminal TUI session.
- Full suite: 2069 passed, 2 skipped.

## 1.0.30 (2026-07-25)

### Bug Fixes
Expand Down
6 changes: 3 additions & 3 deletions landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2280,7 +2280,7 @@
<div class="hero-content">
<div class="hero-badge">
<span class="pulse"></span>
Open-source orchestration for zero-human companies &middot; <span class="orch-version">v1.0.28</span>
Open-source orchestration for zero-human companies &middot; <span class="orch-version">v1.0.31</span>
</div>
<h1>Run multiple AI agents. <em>Without babysitting any of them.</em></h1>
<p class="hero-sub">
Expand Down Expand Up @@ -2337,7 +2337,7 @@ <h1>Run multiple AI agents. <em>Without babysitting any of them.</em></h1>
</div>
</div>
<div class="scroll-hint">Scroll</div>
<div class="hero-version"><span class="orch-version">v1.0.28</span> &mdash; MIT</div>
<div class="hero-version"><span class="orch-version">v1.0.31</span> &mdash; MIT</div>
</section>

<!-- STATS BAR -->
Expand Down Expand Up @@ -2995,7 +2995,7 @@ <h4>Community</h4>
</div>
<div class="footer-bottom">
<span>&copy; 2026 ORCH &mdash; Open Source, MIT License</span>
<span><span class="orch-version">v1.0.28</span> &mdash; Built with TypeScript &mdash; Last updated May 2026</span>
<span><span class="orch-version">v1.0.31</span> &mdash; Built with TypeScript &mdash; Last updated May 2026</span>
</div>
</footer>

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@oxgeneral/orch",
"version": "1.0.30",
"version": "1.0.31",
"description": "AI agent runtime — orchestrate Claude, Cursor, Codex & OpenCode as one team. Multi-agent task automation with state machine, auto-retry, inter-agent messaging, goals and teams. Zero-config CLI + programmatic API.",
"type": "module",
"engines": {
Expand Down
4 changes: 4 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,10 @@ Every department gets the same superpowers: state machine governance, retry, mes

The `shell` adapter is the key: **if it runs in a terminal, it's an agent** — `npm test`, `python bot.py`, Semgrep, `curl`, CRM scripts, data pipelines. Any CLI tool gets state tracking, retry, and coordination for free.

```bash
orch agent add "Test Runner" --adapter shell --command "npm test"
```

<br/>

<!-- Divider -->
Expand Down
7 changes: 7 additions & 0 deletions src/application/agent-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import type { AgentShopTemplate } from '../domain/agent-shop.js';
import type { CreateAgentInput } from '../domain/agent.js';
import { InvalidArgumentsError } from '../domain/errors.js';
import { resolveModel } from '../domain/model-tiers.js';

/** MCP skills use colon-separated names (e.g. `package:skill-name`). */
Expand All @@ -24,6 +25,12 @@ export function templateToAgentInput(
template: AgentShopTemplate,
adapter: string,
): CreateAgentInput {
if (adapter === 'shell') {
throw new InvalidArgumentsError(
'Agent Shop templates require an AI adapter; create shell agents with --command',
);
}

const model = resolveModel(adapter, template.tier);
const skills = adapter === 'claude'
? template.skills
Expand Down
54 changes: 41 additions & 13 deletions src/application/agent-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,35 @@ export class AgentService {
) {}

async create(input: CreateAgentInput): Promise<Agent> {
if (!input.name.trim()) {
const name = input.name.trim();
if (!name) {
throw new InvalidArgumentsError('Agent name is required');
}

const adapter = input.adapter?.trim() || this.config.defaults.agent.adapter;
const isShell = adapter === 'shell';
const command = input.command?.trim() || undefined;
if (isShell && !command) {
throw new InvalidArgumentsError('Shell agents require a command');
}

// Check for duplicate name
const existing = await this.agentStore.getByName(input.name);
const existing = await this.agentStore.getByName(name);
if (existing) {
throw new InvalidArgumentsError(`Agent "${input.name}" already exists`);
throw new InvalidArgumentsError(`Agent "${name}" already exists`);
}

const agent: Agent = {
id: `agt_${nanoid(7)}`,
name: input.name.trim(),
adapter: input.adapter || this.config.defaults.agent.adapter,
name,
adapter,
role: input.role,
config: {
command: input.command,
model: input.model,
effort: input.effort,
approval_policy: input.approval_policy ?? this.config.defaults.agent.approval_policy,
command,
model: isShell ? undefined : input.model,
effort: isShell ? undefined : input.effort,
approval_policy: input.approval_policy ??
(isShell ? 'auto' : this.config.defaults.agent.approval_policy),
max_turns: input.max_turns ?? this.config.defaults.agent.max_turns,
timeout_ms: input.timeout_ms ?? this.config.defaults.agent.timeout_ms,
stall_timeout_ms: input.stall_timeout_ms ?? this.config.defaults.agent.stall_timeout_ms,
Expand Down Expand Up @@ -88,8 +97,20 @@ export class AgentService {
await this.agentStore.delete(id);
}

async update(id: string, fields: { name?: string; adapter?: string; role?: string; model?: string; effort?: Agent['config']['effort'] | ''; approval_policy?: Agent['config']['approval_policy'] }): Promise<Agent> {
async update(id: string, fields: { name?: string; adapter?: string; role?: string; command?: string; model?: string; effort?: Agent['config']['effort'] | ''; approval_policy?: Agent['config']['approval_policy'] }): Promise<Agent> {
const agent = await this.get(id);
const wasShell = agent.adapter === 'shell';

const adapter = fields.adapter !== undefined ? fields.adapter.trim() : agent.adapter;
if (!adapter) throw new InvalidArgumentsError('Agent adapter cannot be empty');
const enteringShell = adapter === 'shell' && !wasShell;

const command = fields.command !== undefined
? fields.command.trim() || undefined
: agent.config.command;
if (adapter === 'shell' && !command) {
throw new InvalidArgumentsError('Shell agents require a command');
}

if (fields.name !== undefined) {
if (!fields.name.trim()) throw new InvalidArgumentsError('Agent name cannot be empty');
Expand All @@ -101,14 +122,21 @@ export class AgentService {
agent.name = fields.name.trim();
}
if (fields.adapter !== undefined) {
const adapter = fields.adapter.trim();
if (!adapter) throw new InvalidArgumentsError('Agent adapter cannot be empty');
agent.adapter = adapter;
}
if (fields.role !== undefined) agent.role = fields.role || undefined;
if (fields.command !== undefined) agent.config.command = command;
if (fields.model !== undefined) agent.config.model = fields.model || undefined;
if (fields.effort !== undefined) agent.config.effort = fields.effort || undefined;
if (fields.approval_policy !== undefined) agent.config.approval_policy = fields.approval_policy;
if (enteringShell) {
agent.config.model = undefined;
agent.config.effort = undefined;
}
if (fields.approval_policy !== undefined) {
agent.config.approval_policy = fields.approval_policy;
} else if (enteringShell) {
agent.config.approval_policy = 'auto';
}

await this.agentStore.save(agent);
return agent;
Expand Down
2 changes: 1 addition & 1 deletion src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const program = new Command();
program
.name('orchestry')
.description('Agents Organizations — CLI orchestrator for AI agents')
.version('1.0.30')
.version('1.0.31')
.option('--json', 'Output as JSON')
.option('--quiet', 'Minimal output (IDs only)')
.option('--no-color', 'Disable colors')
Expand Down
13 changes: 10 additions & 3 deletions src/cli/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ export function registerAgentCommand(program: Command, container: LightContainer

if (opts.edit) {
const { openInEditor, agentToEditorContent, agentFromEditorContent } = await import('../editor.js');
const initial = agentToEditorContent({ name, model: opts.model, role });
const initial = agentToEditorContent({ name, command: opts.command, model: opts.model, role });
const edited = await openInEditor(initial);
const parsed = agentFromEditorContent(edited);
if (parsed.name) name = parsed.name;
if (parsed.command !== undefined) opts.command = parsed.command;
if (parsed.model) opts.model = parsed.model;
if (parsed.role) role = parsed.role;
}
Expand Down Expand Up @@ -161,11 +162,15 @@ export function registerAgentCommand(program: Command, container: LightContainer
console.log();

const pairs: Array<[string, string]> = [
['Adapter', `${a.adapter}${a.config.model ? ` (${a.config.model})` : ''}`],
['Adapter', `${a.adapter}${a.adapter !== 'shell' && a.config.model ? ` (${a.config.model})` : ''}`],
['Status', `${statusIcon(a.status)} ${a.status}`],
['Effort', a.config.effort ?? 'default'],
['Policy', a.config.approval_policy ?? 'auto'],
];
if (a.adapter === 'shell') {
pairs.splice(1, 0, ['Command', a.config.command ?? '—']);
} else {
pairs.splice(2, 0, ['Effort', a.config.effort ?? 'default']);
}
if (a.current_task) pairs.push(['Task', a.current_task]);
if (a.role) pairs.push(['Role', a.role]);
if (a.config.skills?.length) pairs.push(['Skills', a.config.skills.join(', ')]);
Expand All @@ -192,6 +197,7 @@ export function registerAgentCommand(program: Command, container: LightContainer
const { openInEditor, agentToEditorContent, agentFromEditorContent } = await import('../editor.js');
const initial = agentToEditorContent({
name: a.name,
command: a.config.command,
model: a.config.model,
role: a.role,
});
Expand All @@ -202,6 +208,7 @@ export function registerAgentCommand(program: Command, container: LightContainer
const updated = await container.agentService.update(id, {
name: parsed.name,
role: parsed.role,
command: parsed.command,
model: parsed.model,
});

Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,10 @@ export function registerInitCommand(program: Command): void {
return;
}
await runInit(opts);
console.log(` Next: ${dim('orch task add "Create backend agent" --assignee agt_creator')}`);
const next = opts.adapter === 'shell'
? 'orch agent add "Test Runner" --adapter shell --command "npm test"'
: 'orch task add "Create backend agent" --assignee agt_creator';
console.log(` Next: ${dim(next)}`);
console.log();
});
}
23 changes: 21 additions & 2 deletions src/cli/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,19 @@ async function runSingle(container: Container, taskId: string): Promise<void> {
console.log();
console.log(` ${amber('orch')} · running ${taskId} "${task.title}"`);

let targetRunId: string | undefined;
let unsub = () => {};

// Subscribe to events for live output
const unsub = container.eventBus.onAny((event) => {
unsub = container.eventBus.onAny((event) => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });

switch (event.type) {
case 'agent:started':
if (event.taskId === taskId) {
targetRunId = event.runId;
}
break;
case 'agent:output':
console.log(` ${dim(time)} ${getIcon('agentAction')} ${typeof event.data === 'string' ? event.data.slice(0, 80) : ''}`);
break;
Expand All @@ -50,18 +58,29 @@ async function runSingle(container: Container, taskId: string): Promise<void> {
console.log(` ${dim(time)} ${getIcon('failed')} ${event.error}`);
break;
case 'agent:completed':
if (event.runId !== targetRunId) break;
if (event.success) {
printSuccess('Done');
} else {
printError('Failed');
process.exitCode = 1;
}
unsub();
break;
}
});

try {
await container.orchestrator.runTask(taskId);
} finally {
} catch (error) {
unsub();
throw error;
}

// A terminal/non-dispatchable task may not emit a run lifecycle. Normal
// dispatched runs keep the listener until their asynchronous collector emits
// agent:completed, so output and the final exit code are not lost.
if (!targetRunId) {
unsub();
}

Expand Down
5 changes: 3 additions & 2 deletions src/cli/commands/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ export function registerTuiCommand(program: Command, container: Container): void
return container.stateStore.read();
};

const onAddAgent = async (name: string, adapter?: string, opts?: { model?: string; effort?: string; role?: string; approval_policy?: string; skills?: string[] }) => {
const onAddAgent = async (name: string, adapter?: string, opts?: { command?: string; model?: string; effort?: string; role?: string; approval_policy?: string; skills?: string[] }) => {
return container.agentService.create({
name,
adapter: adapter ?? container.config.defaults.agent.adapter,
command: opts?.command || undefined,
model: opts?.model || undefined,
effort: (opts?.effort as import('../../domain/agent.js').ReasoningEffort) || undefined,
role: opts?.role || undefined,
Expand Down Expand Up @@ -113,7 +114,7 @@ export function registerTuiCommand(program: Command, container: Container): void
return container.taskService.update(taskId, fields);
};

const onUpdateAgent = async (agentId: string, fields: { name?: string; adapter?: string; role?: string; model?: string; effort?: string; approval_policy?: string }) => {
const onUpdateAgent = async (agentId: string, fields: { name?: string; adapter?: string; role?: string; command?: string; model?: string; effort?: string; approval_policy?: string }) => {
return container.agentService.update(agentId, {
...fields,
effort: fields.effort as import('../../domain/agent.js').ReasoningEffort | '' | undefined,
Expand Down
6 changes: 6 additions & 0 deletions src/cli/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export function fromEditorContent(content: string): {
export interface AgentEditorFields {
name?: string;
role?: string;
command?: string;
model?: string;
}

Expand All @@ -137,6 +138,7 @@ export interface AgentEditorFields {
* # Role description goes below the --- separator.
* ---
* name: My Agent
* command:
* model: claude-sonnet-4-6
* ---
*
Expand All @@ -145,6 +147,7 @@ export interface AgentEditorFields {
*/
export function agentToEditorContent(fields: {
name: string;
command?: string;
model?: string;
role?: string;
}): string {
Expand All @@ -154,6 +157,7 @@ export function agentToEditorContent(fields: {
'# Role description goes below the second --- separator.',
'---',
`name: ${fields.name}`,
`command: ${fields.command ?? ''}`,
`model: ${fields.model ?? ''}`,
'---',
'',
Expand Down Expand Up @@ -194,6 +198,8 @@ export function agentFromEditorContent(content: string): AgentEditorFields {
const value = kv[2] ?? '';
if (key === 'name') {
result.name = value.trim();
} else if (key === 'command') {
result.command = value.trim();
} else if (key === 'model') {
result.model = value.trim();
}
Expand Down
Loading
Loading