Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { bashesCommand } from '../ui/commands/bashesCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
Expand Down Expand Up @@ -91,6 +92,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
agentsCommand,
bashesCommand,
arenaCommand,
approvalModeCommand,
authCommand,
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/ui/commands/bashesCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { vi, describe, it, expect, beforeEach } from 'vitest';
import { bashesCommand } from './bashesCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core';

function entry(
overrides: Partial<BackgroundShellEntry> = {},
): BackgroundShellEntry {
return {
shellId: 'bg_aaaaaaaa',
command: 'sleep 60',
cwd: '/tmp',
status: 'running',
startTime: Date.now() - 5_000,
outputPath: '/tmp/tasks/sess/shell-bg_aaaaaaaa.output',
abortController: new AbortController(),
...overrides,
};
}

describe('bashesCommand', () => {
let context: CommandContext;
let getAll: ReturnType<typeof vi.fn>;

beforeEach(() => {
getAll = vi.fn().mockReturnValue([]);
context = createMockCommandContext({
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
});

it('reports an empty registry', async () => {
const result = await bashesCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No background shells.',
});
});

it('lists running and terminal entries with status / runtime / output path', async () => {
getAll.mockReturnValue([
entry({
shellId: 'bg_run',
command: 'npm run dev',
status: 'running',
startTime: Date.now() - 12_000,
pid: 1111,
}),
entry({
shellId: 'bg_done',
command: 'npm test',
status: 'completed',
exitCode: 0,
startTime: Date.now() - 70_000,
endTime: Date.now() - 5_000,
outputPath: '/tmp/tasks/sess/shell-bg_done.output',
}),
entry({
shellId: 'bg_fail',
command: 'flaky.sh',
status: 'failed',
error: 'spawn ENOENT',
startTime: Date.now() - 3_000,
endTime: Date.now() - 2_000,
}),
]);

const result = await bashesCommand.action!(context, '');
if (!result || result.type !== 'message') {
throw new Error('expected message result');
}
expect(result.content).toContain('Background shells (3 total)');
expect(result.content).toContain('[bg_run] running');
expect(result.content).toContain('pid=1111');
expect(result.content).toContain('npm run dev');
expect(result.content).toContain('[bg_done] completed (exit 0)');
expect(result.content).toContain('[bg_fail] failed: spawn ENOENT');
expect(result.content).toContain(
'output: /tmp/tasks/sess/shell-bg_done.output',
);
});
});
85 changes: 85 additions & 0 deletions packages/cli/src/ui/commands/bashesCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';

function formatRuntime(ms: number): string {
if (ms < 0) ms = 0;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
Comment thread
wenshao marked this conversation as resolved.
Outdated
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m${remainingSeconds.toString().padStart(2, '0')}s`;
}

function statusLabel(entry: BackgroundShellEntry): string {
switch (entry.status) {
case 'completed':
return `completed (exit ${entry.exitCode ?? '?'})`;
case 'failed':
return `failed: ${entry.error ?? 'unknown error'}`;
case 'cancelled':
return 'cancelled';
case 'running':
return 'running';
default:
return entry.status;
}
}

export const bashesCommand: SlashCommand = {
name: 'bashes',
altNames: ['shells'],
get description() {
return t('List background shells started via the shell tool');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context) => {
const { config } = context.services;
if (!config) {
return {
type: 'message' as const,
messageType: 'error' as const,
content: 'Config not available.',
};
}

const entries = config.getBackgroundShellRegistry().getAll();

if (entries.length === 0) {
return {
type: 'message' as const,
messageType: 'info' as const,
content: 'No background shells.',
};
}

const now = Date.now();
const lines: string[] = [
`Background shells (${entries.length} total)`,
'',
];
for (const entry of entries) {
const endTime = entry.endTime ?? now;
const runtime = formatRuntime(endTime - entry.startTime);
const pidPart = entry.pid !== undefined ? ` pid=${entry.pid}` : '';
lines.push(
`[${entry.shellId}] ${statusLabel(entry)} ${runtime}${pidPart} ${entry.command}`,
);
lines.push(` output: ${entry.outputPath}`);
}

return {
type: 'message' as const,
messageType: 'info' as const,
content: lines.join('\n'),
};
},
};
6 changes: 6 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { PermissionManager } from '../permissions/permission-manager.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js';
import {
DEFAULT_OTLP_ENDPOINT,
DEFAULT_TELEMETRY_TARGET,
Expand Down Expand Up @@ -544,6 +545,7 @@ export class Config {
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
Comment thread
wenshao marked this conversation as resolved.
private readonly backgroundTaskRegistry = new BackgroundTaskRegistry();
private readonly backgroundShellRegistry = new BackgroundShellRegistry();
private extensionManager!: ExtensionManager;
private skillManager: SkillManager | null = null;
private permissionManager: PermissionManager | null = null;
Expand Down Expand Up @@ -2467,6 +2469,10 @@ export class Config {
return this.backgroundTaskRegistry;
}

getBackgroundShellRegistry(): BackgroundShellRegistry {
return this.backgroundShellRegistry;
}

/**
* Whether interactive permission prompts should be auto-denied.
* True for background agents that have no UI to show prompts.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export * from './services/sessionService.js';
export * from './services/sessionTitle.js';
export { stripTerminalControlSequences } from './utils/terminalSafe.js';
export * from './services/shellExecutionService.js';
export * from './services/backgroundShellRegistry.js';
export * from './utils/bareMode.js';

// ============================================================================
Expand Down
132 changes: 132 additions & 0 deletions packages/core/src/services/backgroundShellRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import {
BackgroundShellRegistry,
type BackgroundShellEntry,
} from './backgroundShellRegistry.js';

function makeEntry(
overrides: Partial<BackgroundShellEntry> = {},
): BackgroundShellEntry {
return {
shellId: 's1',
command: 'sleep 60',
cwd: '/tmp',
status: 'running',
startTime: 1000,
outputPath: '/tmp/s1.output',
abortController: new AbortController(),
...overrides,
};
}

describe('BackgroundShellRegistry', () => {
describe('register / get / getAll', () => {
it('round-trips a registered entry by id', () => {
const reg = new BackgroundShellRegistry();
const e = makeEntry({ shellId: 'a' });
reg.register(e);
expect(reg.get('a')).toBe(e);
});

it('returns undefined for unknown id', () => {
const reg = new BackgroundShellRegistry();
expect(reg.get('missing')).toBeUndefined();
});

it('lists all entries via getAll', () => {
const reg = new BackgroundShellRegistry();
const a = makeEntry({ shellId: 'a' });
const b = makeEntry({ shellId: 'b' });
reg.register(a);
reg.register(b);
const all = reg.getAll();
expect(all).toHaveLength(2);
expect(all).toContain(a);
expect(all).toContain(b);
});
});

describe('complete', () => {
it('transitions running → completed with exitCode and endTime', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.complete('a', 0, 2000);
const e = reg.get('a')!;
expect(e.status).toBe('completed');
expect(e.exitCode).toBe(0);
expect(e.endTime).toBe(2000);
});

it('is a no-op when entry is not running', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.cancel('a', 1500);
reg.complete('a', 0, 2000);
const e = reg.get('a')!;
expect(e.status).toBe('cancelled');
expect(e.exitCode).toBeUndefined();
});

it('is a no-op for unknown id', () => {
const reg = new BackgroundShellRegistry();
expect(() => reg.complete('missing', 0, 0)).not.toThrow();
});
});

describe('fail', () => {
it('transitions running → failed with error and endTime', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.fail('a', 'spawn error', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('failed');
expect(e.error).toBe('spawn error');
expect(e.endTime).toBe(2000);
});

it('is a no-op when entry is not running', () => {
const reg = new BackgroundShellRegistry();
reg.register(makeEntry({ shellId: 'a' }));
reg.complete('a', 0, 1500);
reg.fail('a', 'late error', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('completed');
expect(e.error).toBeUndefined();
});
});

describe('cancel', () => {
it('transitions running → cancelled and aborts the signal', () => {
const reg = new BackgroundShellRegistry();
const ac = new AbortController();
reg.register(makeEntry({ shellId: 'a', abortController: ac }));
reg.cancel('a', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('cancelled');
expect(e.endTime).toBe(2000);
expect(ac.signal.aborted).toBe(true);
});

it('is a no-op when entry is already terminal', () => {
const reg = new BackgroundShellRegistry();
const ac = new AbortController();
reg.register(makeEntry({ shellId: 'a', abortController: ac }));
reg.complete('a', 0, 1500);
reg.cancel('a', 2000);
const e = reg.get('a')!;
expect(e.status).toBe('completed');
expect(ac.signal.aborted).toBe(false);
});

it('is a no-op for unknown id', () => {
const reg = new BackgroundShellRegistry();
expect(() => reg.cancel('missing', 0)).not.toThrow();
});
});
});
Loading
Loading