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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tier: T2
change: agent-codex-gx-dmux-home-launcher-2026-04-30-11-31
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# gx dmux home launcher task prompt

## Why

Plain `gx` and `gx agents start --panel` should feel like opening dmux first: show the GitGuardex home launcher immediately, then collect the task inside that launcher. The current parser and panel controller require a task before the panel can open, so the operator has to provide text before seeing the home screen.

## What Changes

- Allow `gx agents start --panel` to start without a task when an interactive panel or panel dry-run is requested.
- Put empty-task panels into a task-input mode where printable keys build the task and Enter launches.
- Route plain interactive `gx` to the same home panel instead of status output.
- Preserve existing dry-run, multi-agent, claims, and non-panel start behavior.

## Impact

The change is isolated to agent launcher parsing, panel state handling, and focused CLI tests. It does not alter branch creation, locks, session metadata, finish flow, or non-panel agent startup.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## ADDED Requirements

### Requirement: Empty panel prompts for task before launch

`gx agents start --panel` SHALL allow the panel to open without a task and collect the task inside the launcher before creating any agent lane.

#### Scenario: TTY panel starts in task input mode

- **WHEN** an operator runs `gx agents start --panel` in a TTY
- **THEN** the GitGuardex launcher SHALL render before any branch/worktree is created
- **AND** the launcher SHALL show a task input prompt
- **AND** printable keys SHALL update the task shown by the launcher
- **AND** Enter SHALL launch only after the task is non-empty.

#### Scenario: scripted panel dry-run renders home without task plans

- **WHEN** `gx agents start --panel --dry-run` runs without a TTY and without a task
- **THEN** the output SHALL render the GitGuardex home panel
- **AND** the output SHALL not render dry-run branch plans until a task exists.

### Requirement: Plain gx opens the interactive launcher home

Plain interactive `gx` SHALL open the same GitGuardex home launcher instead of status output.

#### Scenario: no-argument interactive gx shows home launcher

- **WHEN** an operator runs `gx` with no arguments in a TTY
- **THEN** the command SHALL open the interactive GitGuardex launcher
- **AND** the launcher SHALL ask for the task before launch.

#### Scenario: non-interactive gx keeps status behavior

- **WHEN** `gx` runs with no arguments outside a TTY
- **THEN** the command SHALL keep the existing status behavior.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Tasks

## 1. Spec

- [x] 1.1 Capture empty-task launcher behavior.

## 2. Tests

- [x] 2.1 Cover task input reducer behavior for empty panels.
- [x] 2.2 Cover `gx agents start --panel --dry-run` rendering home without a task.
- [x] 2.3 Cover interactive empty-panel task entry before launch.
- [x] 2.4 Cover parser acceptance for empty `--panel` starts.

## 3. Implementation

- [x] 3.1 Allow empty task only for panel starts.
- [x] 3.2 Add task-input mode to the dmux-style panel controller.
- [x] 3.3 Route plain interactive `gx` to the launcher home.

## 4. Verification

- [x] 4.1 Run focused Node tests.
- Evidence: `node --test test/agents-selection-panel.test.js test/agents-start-dry-run.test.js test/agents-start.test.js test/cli-args-dispatch.test.js test/agents-start-claims.test.js` passed (`33/33`).
- [x] 4.2 Validate OpenSpec change.
- Evidence: `openspec validate agent-codex-gx-dmux-home-launcher-2026-04-30-11-31 --type change --strict` passed.
- [x] 4.3 Smoke `gx agents start --panel --dry-run`.
- Evidence: `node bin/multiagent-safety.js agents --target /home/deadpool/Documents/recodee/gitguardex start --panel --dry-run` rendered the GitGuardex home panel with task input and no dry-run branch plans.

## 5. Cleanup

- [ ] 5.1 Run the finish pipeline: `gx branch finish --branch agent/codex/gx-dmux-home-launcher-2026-04-30-11-31 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 5.2 Record PR URL, final `MERGED` state, and sandbox cleanup evidence.
114 changes: 100 additions & 14 deletions src/agents/selection-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,18 @@ function createAgentSelectionPanelState(options = {}) {
const selections = normalizeAgentSelections({ ...options, maxSelected });
const counts = countsFromSelections(selections);
const firstSelectedIndex = definitions.findIndex((agent) => (counts[agent.id] || 0) > 0);
const task = String(options.task || '');
return {
task: options.task || '',
task,
base: options.base || '',
claims: Array.isArray(options.claims) ? [...options.claims] : [],
maxSelected,
focusIndex: firstSelectedIndex >= 0 ? firstSelectedIndex : 0,
counts,
message: '',
taskInputActive: Object.prototype.hasOwnProperty.call(options, 'taskInputActive')
? Boolean(options.taskInputActive)
: !task.trim(),
message: options.message || '',
};
}

Expand Down Expand Up @@ -144,17 +148,57 @@ function withCount(state, agentId, count, message = '') {
};
}

function normalizePanelKey(value) {
function rawPanelKey(value) {
if (!value) return '';
const raw = Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
return Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
}

function normalizePanelKey(value) {
const raw = rawPanelKey(value);
if (raw === '\u0003') return 'ctrl-c';
if (raw === '\u0015') return 'ctrl-u';
if (raw === '\u001b') return 'escape';
if (raw === '\r' || raw === '\n') return 'enter';
if (raw === '\u007f' || raw === '\b') return 'backspace';
if (raw === '\u001b[A') return 'up';
if (raw === '\u001b[B') return 'down';
return raw.toLowerCase();
}

function printableTaskInput(value) {
const raw = rawPanelKey(value);
if (raw.length !== 1) return '';
const code = raw.charCodeAt(0);
if (code < 32 || code > 126) return '';
return raw;
}

function taskLaunchState(state) {
const task = String(state.task || '').trim();
if (!task) {
return {
state: {
...state,
taskInputActive: true,
message: 'Type a task before launch.',
},
action: 'render',
};
}
if (selectedCountFromPanelState(state) <= 0) {
return { state: { ...state, message: 'Select at least one agent before launch.' }, action: 'render' };
}
return {
state: {
...state,
task,
taskInputActive: false,
message: '',
},
action: 'launch',
};
}

function applyAgentSelectionKey(state = {}, rawKey) {
const definitions = getAgentDefinitions();
const current = {
Expand All @@ -165,14 +209,45 @@ function applyAgentSelectionKey(state = {}, rawKey) {
};
const key = normalizePanelKey(rawKey);

if (key === 'ctrl-c' || key === 'escape' || key === 'q') {
if (key === 'ctrl-c' || key === 'escape' || (!current.taskInputActive && key === 'q')) {
return { state: current, action: 'cancel' };
}
if (key === 'enter' || key === 'n') {
if (selectedCountFromPanelState(current) <= 0) {
return { state: { ...current, message: 'Select at least one agent before launch.' }, action: 'render' };

if (current.taskInputActive) {
if (key === 'enter') {
return taskLaunchState(current);
}
if (key === 'backspace') {
return {
state: {
...current,
task: String(current.task || '').slice(0, -1),
message: '',
},
action: 'render',
};
}
return { state: current, action: 'launch' };
if (key === 'ctrl-u') {
return { state: { ...current, task: '', message: '' }, action: 'render' };
}

const input = printableTaskInput(rawKey);
if (input) {
return {
state: {
...current,
task: `${current.task || ''}${input}`,
message: '',
},
action: 'render',
};
}

return { state: current, action: 'render' };
}

if (key === 'enter' || key === 'n') {
return taskLaunchState(current);
}
if (key === 'up' || key === 'k') {
return {
Expand Down Expand Up @@ -289,6 +364,7 @@ function renderSidebarRows(options, selections, definitions, width, height) {
? options.claims.join(', ')
: 'none';
const topBars = '█'.repeat(Math.max(0, width - 13));
const taskText = options.task ? options.task : (options.taskInputActive ? '_' : '-');
const rows = [
`─ gx ${'─'.repeat(Math.max(0, width - 5))}`,
`▦ gitguardex ${topBars}`.slice(0, width),
Expand All @@ -306,11 +382,13 @@ function renderSidebarRows(options, selections, definitions, width, height) {
}),
'',
' Settings',
` task: ${options.task || '-'}`,
` task: ${taskText}`,
` base: ${options.base || 'current branch'}`,
` Codex accounts: ${codexAccounts}`,
` claims: ${claims}`,
options.message ? ` status: ${options.message}` : '',
options.message
? ` status: ${options.message}`
: (options.taskInputActive ? ' status: Type task, then Enter.' : ''),
];

while (rows.length < height - 5) {
Expand Down Expand Up @@ -345,7 +423,9 @@ function renderLogoCardRows(options, selections, width) {
centerLine('gitguardex', innerWidth),
centerLine('AI developer agent guardrail multiplexer', innerWidth),
centerLine(`Select Agent(s) · Selected: ${total}/${maxSelected}`, innerWidth),
centerLine('Press [n] or Enter to create a new agent', innerWidth),
centerLine(options.taskInputActive
? 'Type task, then press Enter'
: 'Press [n] or Enter to create a new agent', innerWidth),
];

return [
Expand Down Expand Up @@ -393,7 +473,10 @@ function renderDmuxAgentSelectionPanel(options = {}) {
const mainWidth = Math.max(42, width - sidebarWidth - 1);
const sidebar = renderSidebarRows({ ...options, maxSelected }, selections, definitions, sidebarWidth, height);
const main = renderMainRows({ ...options, maxSelected }, selections, mainWidth, height);
const keyLine = padLine(' ↑/↓ navigate · Space toggle · +/- Codex accounts · [n]/Enter launch · ESC cancel ', width);
const keyText = options.taskInputActive
? ' Type task · Backspace edit · Enter launch · ESC cancel '
: ' ↑/↓ navigate · Space toggle · +/- Codex accounts · [n]/Enter launch · ESC cancel ';
const keyLine = padLine(keyText, width);
const lines = [];

for (let index = 0; index < height; index += 1) {
Expand Down Expand Up @@ -438,7 +521,9 @@ function renderAgentSelectionPanel(options = {}) {
`claims: ${claims}`,
options.message ? `status: ${options.message}` : null,
'',
'↑/↓ navigate · Space toggle · +/- Codex accounts · [n]/Enter launch · ESC cancel',
options.taskInputActive
? 'Type task · Backspace edit · Enter launch · ESC cancel'
: '↑/↓ navigate · Space toggle · +/- Codex accounts · [n]/Enter launch · ESC cancel',
].filter((row) => row !== null);
return `${framePanel('Select Agent(s)', rows)}\n`;
}
Expand All @@ -452,6 +537,7 @@ function renderInteractiveAgentSelectionPanel(state = {}, options = {}) {
selections: selectionsFromPanelState(state),
focusedAgentId: focusedAgent(state)?.id,
message: state.message,
taskInputActive: state.taskInputActive,
...options,
});
}
Expand Down
21 changes: 20 additions & 1 deletion src/agents/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ function renderDryRunPlan(plan) {
}

function dryRunStart(options, repoRoot) {
const hasTask = Boolean(String(options.task || '').trim());
if (options.panel && !hasTask && !options.json) {
return renderAgentSelectionPanel({
task: '',
base: options.base,
claims: options.claims,
selections: normalizeAgentSelections(options),
taskInputActive: true,
});
}

const launchOptions = buildLaunchOptions(options);
const plans = launchOptions.map((launchOption) => buildStartPlan(launchOption, repoRoot));
if (options.json) {
Expand Down Expand Up @@ -196,6 +207,7 @@ function panelOptions(options, state) {
const first = selectionsFromPanelState(state)[0];
return {
...options,
task: String(state.task || '').trim(),
agent: first ? first.agent.id : options.agent,
count: 1,
agentSelectionSpecs: specs,
Expand All @@ -204,6 +216,13 @@ function panelOptions(options, state) {

function executePanelSelection(repoRoot, options, state, deps = {}) {
const selectedOptions = panelOptions(options, state);
if (!selectedOptions.task) {
return {
status: 1,
stdout: '',
stderr: `[${TOOL_NAME}] Agent launch requires a task.\n`,
};
}
if (selectedOptions.dryRun) {
return {
status: 0,
Expand All @@ -222,7 +241,7 @@ function writeStream(stream, value) {
}

function shouldUseInteractivePanel(options = {}, stdin = process.stdin, stdout = process.stdout) {
return Boolean(options.panel && options.task && stdin && stdin.isTTY && stdout && stdout.isTTY);
return Boolean(options.panel && stdin && stdin.isTTY && stdout && stdout.isTTY);
}

function startInteractiveAgentPanel(repoRoot, options, deps = {}) {
Expand Down
9 changes: 5 additions & 4 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -515,17 +515,18 @@ function parseAgentsArgs(rawArgs) {
if (options.dryRun && !['start', 'cleanup-sessions'].includes(options.subcommand)) {
throw new Error('--dry-run is only supported with `gx agents start|cleanup-sessions`');
}
if (options.subcommand === 'start' && options.dryRun && !options.task) {
if (options.subcommand === 'start' && options.dryRun && !options.task && !(options.panel && !options.json)) {
throw new Error('gx agents start --dry-run requires a task');
}
if (
options.subcommand === 'start' &&
!options.task &&
(options.agentSelectionSpecs.length > 0 || options.count !== 1 || options.panel)
!options.panel &&
(options.agentSelectionSpecs.length > 0 || options.count !== 1)
) {
throw new Error('gx agents start --agents|--count|--panel requires a task');
throw new Error('gx agents start --agents|--count requires a task');
}
if (options.claims.length > 0 && !options.task) {
if (options.claims.length > 0 && !options.task && !options.panel) {
throw new Error('gx agents start --claim requires a task');
}
if (['files', 'diff', 'locks'].includes(options.subcommand) && !options.branch) {
Expand Down
17 changes: 16 additions & 1 deletion src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2673,7 +2673,7 @@ function agents(rawArgs) {
}

if (options.subcommand === 'start') {
if (options.task && agentsStart.shouldUseInteractivePanel(options, process.stdin, process.stdout)) {
if (agentsStart.shouldUseInteractivePanel(options, process.stdin, process.stdout)) {
agentsStart.startInteractiveAgentPanel(repoRoot, options, {
onDone(result) {
process.exitCode = result.status;
Expand All @@ -2687,6 +2687,11 @@ function agents(rawArgs) {
process.exitCode = 0;
return;
}
if (options.panel && !options.task) {
process.stderr.write('[gitguardex] gx agents start --panel requires an interactive terminal when no task is provided.\n');
process.exitCode = 1;
return;
}
if (options.task) {
const result = agentsStart.startAgentLane(repoRoot, options);
if (result.stdout) process.stdout.write(result.stdout);
Expand Down Expand Up @@ -3744,6 +3749,16 @@ async function main() {
const args = process.argv.slice(2);

if (args.length === 0) {
if (isInteractiveTerminal()) {
const options = parseAgentsArgs(['start', '--panel']);
const repoRoot = resolveRepoRoot(options.target);
agentsStart.startInteractiveAgentPanel(repoRoot, options, {
onDone(result) {
process.exitCode = result.status;
},
});
return;
}
toolchainModule.maybeSelfUpdateBeforeStatus();
toolchainModule.maybeOpenSpecUpdateBeforeStatus();
const statusPayload = status([]);
Expand Down
Loading
Loading