Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b92fde1
feat(core): add a live-session registry and `qwen sessions ps`
qqqys Aug 8, 2026
88cb405
Merge branch 'main' into feat/session-registry
qqqys Aug 8, 2026
a9e9cee
test(cli): restore gemini.test.tsx mocks for the session registry
qqqys Aug 8, 2026
0f0af41
fix(core): keep the session-registry swap independent of the sidecar
qqqys Aug 8, 2026
dc42240
test: pin the session-registry wiring and the /proc starttime field
qqqys Aug 8, 2026
552dfbb
fix(core): do not sweep session records from another PID namespace
qqqys Aug 8, 2026
d62d196
fix(core): key session-registry trust on the record's origin
qqqys Aug 9, 2026
c1f7813
fix(core): reject the uninitialized machine-id sentinel
qqqys Aug 9, 2026
421774f
fix(core): stop noFollow writes and unprovable records from being tru…
qqqys Aug 9, 2026
2423dcf
fix(core): bind registry writes to the entry they validated
qqqys Aug 9, 2026
9c16eb1
fix(core): stop the registry's fs.constants read from failing module …
qqqys Aug 9, 2026
8621320
fix(core): stop registry reads hanging, over-reading, or clobbering s…
qqqys Aug 10, 2026
a5bac99
fix(core): close the three Criticals from review round 7
qqqys Aug 10, 2026
f9f3ddc
Merge branch 'main' into feat/session-registry
qwen-code-dev-bot Aug 10, 2026
124a96f
Merge branch 'main' into feat/session-registry
qwen-code-dev-bot Aug 10, 2026
040fad2
Merge branch 'main' into feat/session-registry
wenshao Aug 10, 2026
01d9d6a
fix(core): clear a non-directory squatting on the registry dir path
qqqys Aug 11, 2026
36b987a
fix(cli): stop ellipsizing names that fit, and close three test gaps
qqqys Aug 11, 2026
65661f0
fix(core): let listLiveSessions report that it could not look
qqqys Aug 11, 2026
375bb56
fix(core): clear directory blocking session registration
qqqys Aug 11, 2026
55cf254
fix(core): harden session registry cleanup
qqqys Aug 11, 2026
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
14 changes: 12 additions & 2 deletions packages/cli/src/commands/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@

import { describe, it, expect, vi } from 'vitest';

// Subcommand modules are stubbed so this file tests wiring only — loading
// the real ones would pull the whole core barrel in behind them.
vi.mock('./sessions/list.js', () => ({
listCommand: {
command: 'list',
describe: 'List sessions',
},
}));

vi.mock('./sessions/ps.js', () => ({
psCommand: {
command: 'ps',
describe: 'List Qwen Code sessions running right now',
},
}));

import { sessionsCommand } from './sessions.js';
import { type Argv } from 'yargs';
import yargs from 'yargs';
Expand Down Expand Up @@ -42,7 +51,7 @@ describe('sessions command', () => {
expect(options.key).toHaveProperty('help');
});

it('should register list subcommand', () => {
it('should register list and ps subcommands', () => {
const mockYargs = {
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
Expand All @@ -55,12 +64,13 @@ describe('sessions command', () => {
}
builder(mockYargs as unknown as Argv);

expect(mockYargs.command).toHaveBeenCalledTimes(1);
expect(mockYargs.command).toHaveBeenCalledTimes(2);

const commandCalls = mockYargs.command.mock.calls;
const commandNames = commandCalls.map((call) => call[0].command);

expect(commandNames).toContain('list');
expect(commandNames).toContain('ps');

expect(mockYargs.demandCommand).toHaveBeenCalledWith(
1,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@

import type { CommandModule, Argv } from 'yargs';
import { listCommand } from './sessions/list.js';
import { psCommand } from './sessions/ps.js';

export const sessionsCommand: CommandModule = {
command: 'sessions',
describe: 'Manage Qwen Code sessions',
builder: (yargs: Argv) =>
yargs
.command(listCommand)
.command(psCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
// demandCommand(1) ensures a subcommand is always required;
Expand Down
132 changes: 132 additions & 0 deletions packages/cli/src/commands/sessions/ps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core';

const listLiveSessions = vi.fn();

vi.mock('@qwen-code/qwen-code-core', () => ({
listLiveSessions: (...args: unknown[]) => listLiveSessions(...args),
}));

const stdout: string[] = [];
const stderr: string[] = [];

vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: (line: string) => stdout.push(line),
writeStderrLine: (line: string) => stderr.push(line),
}));

const { psCommand, formatAge } = await import('./ps.js');

function record(
over: Partial<SessionRegistryRecord> = {},
): SessionRegistryRecord {
return {
schemaVersion: 1,
pid: 4242,
procStart: '123',
sessionId: 'sess-1',
cwd: '/w/app',
name: 'app-ab',
kind: 'interactive',
startedAt: Date.now() - 90_000,
qwenVersion: '1.0.0',
peerProtocol: 1,
...over,
};
}

async function run(argv: Record<string, unknown>): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (psCommand.handler as any)(argv);
}

beforeEach(() => {
stdout.length = 0;
stderr.length = 0;
listLiveSessions.mockReset();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('formatAge', () => {
it('scales the unit with the magnitude', () => {
expect(formatAge(5_000)).toBe('5s');
expect(formatAge(90_000)).toBe('1m');
expect(formatAge(3 * 3600_000)).toBe('3h');
expect(formatAge(50 * 3600_000)).toBe('2d');
});

it('clamps a record from the future to zero rather than showing a negative age', () => {
expect(formatAge(-10_000)).toBe('0s');
});
});

describe('qwen sessions ps', () => {
it('prints a table of live sessions', async () => {
listLiveSessions.mockResolvedValue([record()]);
await run({ json: false, all: false });

expect(stdout[0]).toMatch(/^NAME\s+PID\s+AGE\s+DIRECTORY$/);
expect(stdout[1]).toContain('app-ab');
expect(stdout[1]).toContain('4242');
expect(stdout[1]).toContain('/w/app');
});

it('says so plainly when nothing else is running', async () => {
listLiveSessions.mockResolvedValue([]);
await run({ json: false, all: false });
expect(stdout).toEqual(['No other Qwen Code sessions are running.']);
});

it('emits one JSON object per line with no header', async () => {
listLiveSessions.mockResolvedValue([record(), record({ pid: 7 })]);
await run({ json: true, all: false });

expect(stdout).toHaveLength(2);
expect(JSON.parse(stdout[0]).pid).toBe(4242);
expect(JSON.parse(stdout[1]).pid).toBe(7);
});

it('prints nothing on stdout for an empty JSON listing', async () => {
listLiveSessions.mockResolvedValue([]);
await run({ json: true, all: false });
expect(stdout).toEqual([]);
});

it('excludes this process unless --all is passed', async () => {
listLiveSessions.mockResolvedValue([]);

await run({ json: true, all: false });
expect(listLiveSessions).toHaveBeenLastCalledWith({ includeSelf: false });

await run({ json: true, all: true });
expect(listLiveSessions).toHaveBeenLastCalledWith({ includeSelf: true });
});

it('neutralizes control sequences coming from another process record', async () => {
listLiveSessions.mockResolvedValue([
record({ name: 'evil\r', cwd: '/w/a\nb' }),
]);
await run({ json: false, all: false });

const row = stdout[1];
expect(row).not.toContain('');
expect(row).not.toContain('\r');
expect(row).not.toContain('\n');
});

it('truncates an over-long name instead of breaking the columns', async () => {
listLiveSessions.mockResolvedValue([record({ name: 'x'.repeat(80) })]);
await run({ json: false, all: false });
expect(stdout[1]).toContain('...');
expect(stdout[1]).toContain('4242');
});
});
152 changes: 152 additions & 0 deletions packages/cli/src/commands/sessions/ps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

/**
* `qwen sessions ps` — list the Qwen Code sessions running right now.
*
* The sibling `qwen sessions list` walks saved transcripts; this walks the
* live-process registry, so the two answer different questions: "what have
* I worked on" versus "what is running on this machine at this moment".
*/

import type { CommandModule, Argv } from 'yargs';
import {
listLiveSessions,
type SessionRegistryRecord,
} from '@qwen-code/qwen-code-core';
import stringWidth from 'string-width';
import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';

/** Fixed column widths for the human-readable table (exported for tests). */
export const NAME_COL = 22;
export const PID_COL = 9;
export const AGE_COL = 10;

interface PsArgs {
json?: boolean;
all?: boolean;
}

/**
* Sanitize a value for terminal output.
*
* `cwd` and `name` originate from another process's on-disk record, so
* they are attacker-influenced in exactly the way a log line is: a record
* containing an ANSI sequence or a stray `\r` could otherwise repaint or
* misalign this table. Mirrors `sessions list`.
*/
function sanitize(value: string): string {
const stripped = value.replace(/[\r\n\t]/g, '');
const escaped = escapeAnsiCtrlCodes(stripped);
// eslint-disable-next-line no-control-regex
return escaped.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
}

function padDisplay(str: string, width: number): string {
const currentWidth = stringWidth(str);
if (currentWidth >= width) return str;
return str + ' '.repeat(width - currentWidth);
}

function truncate(str: string, maxLen: number): string {
if (stringWidth(str) <= maxLen) return str;
const suffix = maxLen > 3 ? '...' : '';
const target = maxLen - stringWidth(suffix);
let result = '';
let w = 0;
for (const char of str) {
const cw = stringWidth(char);
if (w + cw > target) break;
result += char;
w += cw;
}
return result + suffix;
}

/**
* Render an age as a short, human-scannable string.
*
* A negative delta means the record's clock ran ahead of ours (a paused
* VM, a corrected clock). Showing "-3m" reads as a bug, so clamp to 0.
*/
export function formatAge(ms: number): string {
const seconds = Math.max(0, Math.floor(ms / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}

function outputHuman(records: SessionRegistryRecord[], now: number): void {
writeStdoutLine(
padDisplay('NAME', NAME_COL) +
padDisplay('PID', PID_COL) +
padDisplay('AGE', AGE_COL) +
'DIRECTORY',
);
for (const record of records) {
writeStdoutLine(
padDisplay(truncate(sanitize(record.name), NAME_COL - 2), NAME_COL) +
Comment thread
qqqys marked this conversation as resolved.
Resolved
padDisplay(String(record.pid), PID_COL) +
padDisplay(formatAge(now - record.startedAt), AGE_COL) +
sanitize(record.cwd),
);
}
}

async function handlePs(argv: PsArgs): Promise<void> {
let records: SessionRegistryRecord[];
try {
records = await listLiveSessions({ includeSelf: argv.all ?? false });
Comment thread
qqqys marked this conversation as resolved.
Resolved
} catch (err) {
writeStderrLine(
`Error: failed to read the session registry: ${
err instanceof Error ? err.message : String(err)
}`,
);
process.exit(1);
return;
}

const now = Date.now();

if (argv.json) {
for (const record of records) {
writeStdoutLine(JSON.stringify(record));
}
return;
}

if (records.length === 0) {
writeStdoutLine('No other Qwen Code sessions are running.');
return;
}

outputHuman(records, now);
}

export const psCommand: CommandModule<unknown, PsArgs> = {
command: 'ps',
describe: 'List Qwen Code sessions running right now',
builder: (yargs: Argv) =>
yargs
.option('json', {
type: 'boolean',
describe: 'Output as JSON Lines',
default: false,
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The real yargs surface of the new ps command (subcommand name, --json/--all options, option→handler wiring) is never exercised by any test. — Failure scenario: sessions.test.ts mocks ./sessions/ps.js wholesale, so its expect(commandNames).toContain('ps') asserts the mock's name, not the real file's; ps.test.ts calls (psCommand.handler as any)(argv) with hand-built argv, so yargs builder never runs in any test. A rename of the real command: 'ps', a typo'd option key, or a dropped type: 'boolean' ships with every test green, and qwen sessions ps --json would then silently print the human table.

Suggested change
.option('json', {
type: 'boolean',
describe: 'Output as JSON Lines',
default: false,
})
// Add one test that parses the real psCommand through yargs and asserts
// argv.json / argv.all reach the handler for --json / --all, or drop the
// sessions.test.ts mock and register the real module.
中文说明

新命令 ps 的真实 yargs 表面(子命令名、--json/--all 选项、选项到 handler 的接线)没有任何测试覆盖。sessions.test.ts 整体 mock 了 ./sessions/ps.js,其 toContain('ps') 断言的是 mock 的名字;ps.test.ts 用手工构造的 argv 直接调 handler,yargs builder 从不执行。真实命令改名、选项键拼错或丢掉 type: 'boolean' 都会在全绿测试下合入,qwen sessions ps --json 将静默打印人类表格。

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed, and I am leaving this open rather than claiming it.

ps.test.ts now calls psCommand.builder against a stub that records the options, so a dropped type: 'boolean' or a typo-ed option key is caught. What is still true is the part you led with: real yargs never parses in any test, and sessions.test.ts still mocks ./sessions/ps.js wholesale, so its expect(commandNames).toContain('ps') continues to assert the mock's name rather than the real module's. A rename of command: 'ps' would still ship green.

Your call on whether that is worth a real-yargs parse test on this PR or a follow-up — say the word and I will add it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked in #8918.

.option('all', {
type: 'boolean',
describe: 'Include this process, if it is itself a registered session',
default: false,
}),
handler: async (argv) => {
await handlePs(argv);
},
};
Loading
Loading