diff --git a/scripts/dev-link.sh b/scripts/dev-link.sh new file mode 100755 index 0000000..ea9f4ba --- /dev/null +++ b/scripts/dev-link.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")/.." +echo "Building..." +npm run build +echo "Linking..." +npm link +echo "Done — 'orch' now points to local build." diff --git a/scripts/dev-unlink.sh b/scripts/dev-unlink.sh new file mode 100755 index 0000000..6122840 --- /dev/null +++ b/scripts/dev-unlink.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +npm unlink -g @oxgeneral/orch +echo "Done — 'orch' restored to globally installed version." diff --git a/src/application/orchestrator.ts b/src/application/orchestrator.ts index 900539c..8e22a64 100644 --- a/src/application/orchestrator.ts +++ b/src/application/orchestrator.ts @@ -23,6 +23,7 @@ import { } from '../domain/transitions.js'; import { NoAgentsError, TaskAlreadyRunningError, LockConflictError, WorkspaceError, classifyAdapterError } from '../domain/errors.js'; import { scopesOverlap, ScopeIndex } from '../domain/scope.js'; +import { isModelTier, resolveModel } from '../domain/model-tiers.js'; import { acquireLock, releaseLock, touchLock } from '../infrastructure/storage/lock.js'; import type { ITaskStore, IAgentStore, IRunStore, IStateStore, IContextStore, IGoalStore } from '../infrastructure/storage/interfaces.js'; import { CachedTaskStore, CachedAgentStore, CachedGoalStore } from '../infrastructure/storage/cached-stores.js'; @@ -1097,6 +1098,12 @@ export class Orchestrator { const abortController = new AbortController(); this.abortControllers.set(taskId, abortController); + // Resolve semantic tier aliases (capable/balanced/fast) → concrete model strings + const resolvedConfig = { ...agentData.config }; + if (resolvedConfig.model && isModelTier(resolvedConfig.model)) { + resolvedConfig.model = resolveModel(agent.adapter, resolvedConfig.model) || undefined; + } + const handle = adapter.execute({ prompt, systemPrompt, @@ -1107,7 +1114,7 @@ export class Orchestrator { ORCH_AGENT_NAME: agent.name, ORCH_TASK_ID: task.id, }, - config: agentData.config, + config: resolvedConfig, signal: abortController.signal, }); diff --git a/src/domain/model-tiers.ts b/src/domain/model-tiers.ts index e0b178e..ea204d7 100644 --- a/src/domain/model-tiers.ts +++ b/src/domain/model-tiers.ts @@ -27,12 +27,12 @@ export type ModelTier = 'capable' | 'balanced' | 'fast'; */ export const MODEL_TIER_MAP: Record> = { claude: { - capable: 'claude-opus-4-6', + capable: 'claude-opus-4-7', balanced: 'claude-sonnet-4-6', fast: 'claude-haiku-4-6', }, opencode: { - capable: 'openrouter/anthropic/claude-opus-4.6', + capable: 'openrouter/anthropic/claude-opus-4.7', balanced: '', fast: 'openrouter/google/gemini-2.5-flash', }, diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4788d27..5030c02 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -419,6 +419,7 @@ export function App({ const inputHook = useTextInput(); const inputValue = inputHook.value; const [wizardConfig, setWizardConfig] = useState(null); + const [wizardStepType, setWizardStepType] = useState<'text' | 'select' | 'textarea' | 'multiselect' | null>(null); /** Temp file paths for images pasted via Ctrl+I during task wizard */ const [pendingAttachments, setPendingAttachments] = useState([]); @@ -1009,7 +1010,7 @@ export function App({ targetId: agent.id, }); setInputMode('wizard'); - }, [liveTeams]); + }, [liveTeams, liveAgents]); const launchConfigWizard = useCallback(() => { setWizardConfig({ @@ -1122,6 +1123,9 @@ export function App({ onUpdateAgent(targetId, { name: fields.name, role: fields.role, model: fields.model, effort: fields.effort }).then( (agent) => { addMessage(`\u2713 Updated agent "${agent.name}"`, tuiColors.green); + // Optimistically merge returned agent into local state so reopening the + // editor immediately after save shows fresh values without waiting for refreshAll. + setLiveAgents((prev) => prev.map((a) => a.id === agent.id ? agent : a)); // Handle team change const teamOps: Promise[] = []; if (oldTeamId && oldTeamId !== newTeamId && onLeaveTeam) { @@ -2521,6 +2525,7 @@ export function App({ height={feedH} onPasteImage={isPasteCapable ? handlePasteImage : undefined} onSuggestionSelected={wizardConfig.kind === 'agent' ? handleSuggestionSelected : undefined} + onStepChange={(step) => setWizardStepType(step ? step.type : null)} footerExtra={ pendingAttachments.length > 0 && isPasteCapable ? `\uD83D\uDCCE${pendingAttachments.length}` : undefined @@ -2627,6 +2632,7 @@ export function App({ width={W} hasSuggestions={showSuggestions} onboardingCompleted={initialState.onboardingCompleted} + wizardStepType={inputMode === 'wizard' ? wizardStepType : null} /> ); diff --git a/src/tui/components/CommandBar.tsx b/src/tui/components/CommandBar.tsx index 37ac85b..428ddd8 100644 --- a/src/tui/components/CommandBar.tsx +++ b/src/tui/components/CommandBar.tsx @@ -38,11 +38,13 @@ export interface CommandBarProps { width: number; hasSuggestions?: boolean; onboardingCompleted?: boolean; + /** When a wizard is open, render a wizard-specific hint row instead of the app nav hints. */ + wizardStepType?: 'text' | 'select' | 'textarea' | 'multiselect' | null; } export const CommandBar = React.memo(function CommandBar({ mode, value, completion, activeView, canRun, canNew, canApprove, canReject, canCancel, canDelete, canUndo, canEdit, canForceStop, canToggleAuto, autoActive, canPause, isPaused, canToggleShowAll, showAllActive, hasDetail, - itemCount, itemLabel, width, hasSuggestions, onboardingCompleted, + itemCount, itemLabel, width, hasSuggestions, onboardingCompleted, wizardStepType, }: CommandBarProps) { if (mode === 'command') { const hintText = hasSuggestions @@ -64,6 +66,67 @@ export const CommandBar = React.memo(function CommandBar({ ); } + // Wizard mode — show step-specific hints (overrides the app nav hints) + if (wizardStepType) { + const hint = + wizardStepType === 'textarea' ? ( + <> + Ctrl+S + save description + {' '} + Enter + newline + {' '} + {'\u2190\u2191\u2192\u2193'} + navigate + {' '} + Esc + back + + ) : wizardStepType === 'select' ? ( + <> + {'\u2191\u2193'} + select + {' '} + Enter + confirm + {' '} + Esc + back + + ) : wizardStepType === 'multiselect' ? ( + <> + {'\u2191\u2193'} + move + {' '} + Space + toggle + {' '} + Enter + confirm + {' '} + Esc + back + + ) : ( + <> + Enter + confirm + {' '} + {'\u2190\u2192'} + move + {' '} + Esc + back + + ); + return ( + + {hint} + + ); + } + // Navigate mode — hotkey hints return ( diff --git a/src/tui/components/FormWizard.tsx b/src/tui/components/FormWizard.tsx index f095572..583dd33 100644 --- a/src/tui/components/FormWizard.tsx +++ b/src/tui/components/FormWizard.tsx @@ -55,6 +55,8 @@ export interface FormWizardProps { footerExtra?: string; /** Called when user selects a suggestion from a text step's suggestion list */ onSuggestionSelected?: (suggestionValue: string) => void; + /** Called whenever the active step changes, so parent can reflect step type in the bottom bar. */ + onStepChange?: (step: WizardStep | null) => void; } const CURSOR = '\u2588'; // █ (used by textarea render) @@ -98,8 +100,12 @@ function wordBoundaryForward(text: string, pos: number): number { return i; } -export function FormWizard({ title, steps, onComplete, onCancel, width, height, onPasteImage, footerExtra, onSuggestionSelected }: FormWizardProps) { +export function FormWizard({ title, steps, onComplete, onCancel, width, height, onPasteImage, footerExtra, onSuggestionSelected, onStepChange }: FormWizardProps) { const [currentStep, setCurrentStep] = useState(0); + useEffect(() => { + onStepChange?.(steps[currentStep] ?? null); + return () => { onStepChange?.(null); }; + }, [currentStep, steps, onStepChange]); const [values, setValues] = useState>({}); // Unified text input for single-line text steps (replaces textInput + cursorPos). @@ -434,8 +440,8 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, } if (step.type === 'textarea') { - // Ctrl+Enter / Cmd+Enter: confirm textarea - if (key.return && (key.ctrl || key.meta)) { + // Ctrl+Enter / Cmd+Enter / Ctrl+S: confirm textarea + if ((key.return && (key.ctrl || key.meta)) || (key.ctrl && input === 's')) { const val = taLines.join('\n').trim(); if (step.required && !val) { setDirty(true); return; } if (validationError !== null) { @@ -728,7 +734,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, {step.label} {step.required && *} - {!step.required && (optional, {step.type === 'textarea' ? `${CMD_KEY}+Enter` : 'Enter'} to skip)} + {!step.required && (optional, {step.type === 'textarea' ? `Ctrl+S` : 'Enter'} to skip)} {/* Step description / guidance */} @@ -899,7 +905,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, : step.type === 'multiselect' ? '\u2191\u2193 move Space toggle Enter confirm' : step.type === 'textarea' - ? `Enter newline ${CMD_KEY}+Enter confirm \u2190\u2191\u2192\u2193 navigate` + ? `Ctrl+S save Enter newline \u2190\u2191\u2192\u2193 navigate` : browsingSuggestions ? '\u2191\u2193 browse Enter select \u2191 back to input' : step.suggestions diff --git a/src/tui/components/TextInput.tsx b/src/tui/components/TextInput.tsx index b786414..35d0e80 100644 --- a/src/tui/components/TextInput.tsx +++ b/src/tui/components/TextInput.tsx @@ -116,8 +116,8 @@ export function TextInput({ return ( {prefixStr && {prefixStr}} - {placeholder && {placeholder}} {showCursor && {CURSOR_CHAR}} + {placeholder && {placeholder}} ); } diff --git a/src/tui/wizardConfigs.ts b/src/tui/wizardConfigs.ts index 984f12b..3542153 100644 --- a/src/tui/wizardConfigs.ts +++ b/src/tui/wizardConfigs.ts @@ -19,7 +19,11 @@ import { isMcpSkill } from '../application/agent-factory.js'; // ── Model catalogs per adapter ── const CLAUDE_MODELS = [ - { value: 'claude-opus-4-6', label: 'Claude Opus 4.6', hint: 'most capable' }, + { value: 'capable', label: 'Capable tier (auto)', hint: 'always resolves to latest flagship' }, + { value: 'balanced', label: 'Balanced tier (auto)', hint: 'always resolves to latest sonnet' }, + { value: 'fast', label: 'Fast tier (auto)', hint: 'always resolves to latest haiku' }, + { value: 'claude-opus-4-7', label: 'Claude Opus 4.7', hint: 'most capable, latest' }, + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6', hint: 'previous flagship' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', hint: 'fast, balanced' }, { value: 'claude-haiku-4-6', label: 'Claude Haiku 4.6', hint: 'fastest, cheapest' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', hint: 'extended thinking' }, @@ -48,8 +52,9 @@ const CURSOR_MODELS = [ const OPENCODE_MODELS = [ { value: '', label: 'Default', hint: 'use model configured in opencode' }, + { value: 'openrouter/anthropic/claude-opus-4.7', label: 'Claude Opus 4.7', hint: 'most capable, latest' }, { value: 'openrouter/anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6', hint: 'fast, balanced' }, - { value: 'openrouter/anthropic/claude-opus-4.6', label: 'Claude Opus 4.6', hint: 'most capable' }, + { value: 'openrouter/anthropic/claude-opus-4.6', label: 'Claude Opus 4.6', hint: 'previous flagship' }, { value: 'openrouter/google/gemini-2.5-pro', label: 'Gemini 2.5 Pro', hint: 'Google' }, { value: 'openrouter/google/gemini-2.5-flash', label: 'Gemini 2.5 Flash', hint: 'Google, fast' }, { value: 'openrouter/deepseek/deepseek-v3.2', label: 'DeepSeek V3.2', hint: 'open-source' }, diff --git a/test/unit/application/orchestrator-tier-aliases.test.ts b/test/unit/application/orchestrator-tier-aliases.test.ts new file mode 100644 index 0000000..f6343fa --- /dev/null +++ b/test/unit/application/orchestrator-tier-aliases.test.ts @@ -0,0 +1,160 @@ +/** + * Tests that semantic tier aliases (capable/balanced/fast) stored in agent YAML + * are resolved to concrete model strings before adapter.execute() is called. + * + * This is intentional behaviour (option b): agents can store a tier alias so + * they automatically ride the latest flagship whenever MODEL_TIER_MAP is bumped, + * without requiring a migration of every agent YAML file. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Orchestrator } from '../../../src/application/orchestrator.js'; +import { AdapterRegistry } from '../../../src/infrastructure/adapters/registry.js'; +import type { IAgentAdapter, ExecuteHandle, AgentEvent } from '../../../src/infrastructure/adapters/interface.js'; +import { MODEL_TIER_MAP } from '../../../src/domain/model-tiers.js'; +import { + makeTask, + makeAgent, + createMockTaskStore, + createMockAgentStore, + buildDeps, +} from './helpers.js'; + +vi.mock('../../../src/infrastructure/storage/lock.js', () => ({ + acquireLock: vi.fn(async () => ({ acquired: true, pid: process.pid })), + releaseLock: vi.fn(async () => {}), + touchLock: vi.fn(async () => {}), +})); + +function createCapturingAdapter(): { + adapter: IAgentAdapter; + getLastConfig: () => Record | undefined; +} { + let lastConfig: Record | undefined; + + const adapter: IAgentAdapter = { + kind: 'claude', + test: vi.fn(async () => ({ ok: true })), + execute: vi.fn((params): ExecuteHandle => { + lastConfig = params.config as Record; + return { + pid: 11111, + events: (async function* (): AsyncGenerator { + yield { type: 'output', data: 'done' }; + })(), + }; + }), + stop: vi.fn(async () => {}), + }; + + return { adapter, getLastConfig: () => lastConfig }; +} + +function waitForDispatch(ms = 300): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('Orchestrator tier alias resolution at dispatch', () => { + let orchestrator: Orchestrator; + + afterEach(() => { + const o = orchestrator as any; + if (o.intervalId) { clearInterval(o.intervalId); o.intervalId = null; } + if (o.immediateDispatchTimer) { clearTimeout(o.immediateDispatchTimer); o.immediateDispatchTimer = null; } + if (o.saveStateTimer) { clearTimeout(o.saveStateTimer); o.saveStateTimer = null; } + o.shuttingDown = true; + o.removeSignalHandlers?.(); + o.lockAcquired = false; + }); + + it('resolves "capable" tier alias to the current claude flagship before dispatch', async () => { + const task = makeTask({ status: 'todo' }); + const agent = makeAgent({ adapter: 'claude', config: { approval_policy: 'auto', model: 'capable' } }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + const config = getLastConfig(); + expect(config).not.toBeUndefined(); + expect(config!['model']).toBe(MODEL_TIER_MAP.claude.capable); + expect(config!['model']).toBe('claude-opus-4-7'); + }); + + it('resolves "balanced" tier alias before dispatch', async () => { + const task = makeTask({ status: 'todo', id: 'tsk_bal' }); + const agent = makeAgent({ id: 'agt_bal', adapter: 'claude', config: { approval_policy: 'auto', model: 'balanced' } }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + expect(getLastConfig()!['model']).toBe(MODEL_TIER_MAP.claude.balanced); + }); + + it('resolves "fast" tier alias before dispatch', async () => { + const task = makeTask({ status: 'todo', id: 'tsk_fast' }); + const agent = makeAgent({ id: 'agt_fast', adapter: 'claude', config: { approval_policy: 'auto', model: 'fast' } }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + expect(getLastConfig()!['model']).toBe(MODEL_TIER_MAP.claude.fast); + }); + + it('passes concrete model strings through unchanged', async () => { + const task = makeTask({ status: 'todo', id: 'tsk_concrete' }); + const agent = makeAgent({ + id: 'agt_concrete', + adapter: 'claude', + config: { approval_policy: 'auto', model: 'claude-sonnet-4-6' }, + }); + + const { adapter, getLastConfig } = createCapturingAdapter(); + const registry = new AdapterRegistry(); + registry.register(adapter); + + const deps = buildDeps({ + taskStore: createMockTaskStore([task]), + agentStore: createMockAgentStore([agent]), + adapterRegistry: registry, + }); + + orchestrator = new Orchestrator(deps); + await orchestrator.startWatch({ skipAutonomousSeeding: true }); + await waitForDispatch(); + + expect(getLastConfig()!['model']).toBe('claude-sonnet-4-6'); + }); +}); diff --git a/test/unit/domain/model-tiers.test.ts b/test/unit/domain/model-tiers.test.ts index 736c055..067753c 100644 --- a/test/unit/domain/model-tiers.test.ts +++ b/test/unit/domain/model-tiers.test.ts @@ -9,8 +9,8 @@ import { } from '../../../src/domain/model-tiers.js'; describe('resolveModel', () => { - it('claude capable → opus', () => { - expect(resolveModel('claude', 'capable')).toBe('claude-opus-4-6'); + it('claude capable → opus 4.7 (current flagship)', () => { + expect(resolveModel('claude', 'capable')).toBe('claude-opus-4-7'); }); it('claude balanced → sonnet', () => { diff --git a/test/unit/tui/form-wizard.test.tsx b/test/unit/tui/form-wizard.test.tsx index 7b0a101..5b48e2a 100644 --- a/test/unit/tui/form-wizard.test.tsx +++ b/test/unit/tui/form-wizard.test.tsx @@ -109,7 +109,7 @@ describe('FormWizard textarea', () => { await delay(50); const output = lastFrame()!; expect(output).toContain('Enter newline'); - expect(output).toContain('Enter confirm'); + expect(output).toContain('Ctrl+S'); expect(output).toContain('navigate'); }); @@ -243,6 +243,52 @@ describe('FormWizard textarea', () => { expect(onComplete).not.toHaveBeenCalled(); }); + /* ── Ctrl+S confirms textarea ── */ + + it('Ctrl+S confirms textarea and calls onComplete', async () => { + const onComplete = vi.fn(); + const onCancel = vi.fn(); + const { stdin } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextareaSteps(), + onComplete, + onCancel, + width: 60, + height: 20, + }), + ); + await delay(50); + + stdin.write('Hello world'); + await delay(50); + stdin.write('\x13'); // Ctrl+S to confirm + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ body: 'Hello world' }); + }); + + it('Ctrl+S confirms empty optional textarea', async () => { + const onComplete = vi.fn(); + const onCancel = vi.fn(); + const { stdin } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextareaSteps(), + onComplete, + onCancel, + width: 60, + height: 20, + }), + ); + await delay(50); + + stdin.write('\x13'); // Ctrl+S on empty optional field should confirm + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ body: '' }); + }); + /* ── Cursor navigation ── */ it('←→ arrows move cursor in textarea', async () => {