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
55 changes: 52 additions & 3 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,9 +641,10 @@ These commands are run from the shell as `qwen <subcommand>` before starting an

### Session Management

| Command | Description | Usage Examples |
| -------------------- | --------------------------------- | ------------------------------------------------------------ |
| `qwen sessions list` | List recent conversation sessions | `qwen sessions list`, `qwen sessions list --json --limit 50` |
| Command | Description | Usage Examples |
| -------------------- | ------------------------------------------- | ------------------------------------------------------------ |
| `qwen sessions list` | List recent conversation sessions | `qwen sessions list`, `qwen sessions list --json --limit 50` |
| `qwen sessions ps` | List interactive sessions running right now | `qwen sessions ps`, `qwen sessions ps --json` |

#### `qwen sessions list`

Expand Down Expand Up @@ -682,3 +683,51 @@ qwen sessions list --limit 50
# Output as JSON for scripting
qwen sessions list --json | jq .
```

#### `qwen sessions ps`

Lists the interactive Qwen Code sessions running on this machine right
now. `sessions list` walks saved transcripts ("what have I worked on");
Comment on lines +689 to +690

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 docs promise machine-local listing and dead-only sweeping that tokenless platforms cannot honor when the home directory is shared across machines: on macOS/Windows procStart and pidNs are both null, so in listLiveSessions the namespace check passes (null === null), the boot-id guard is vacuous (recordBootId derives null), and isSameProcess degrades to bare kill(pid, 0). Probe-verified against a model of a tokenless-platform reader: a machine-A-shaped record (pidNs: null, procStart: null) is unlinked by machine B's sweep (recordSurvives=false); the flip arm with a Linux reader (an identity to compare) keeps it; with a live-PID collision the foreign session is instead listed as local with its cwd/name. Both outcomes contradict "on this machine right now" and "Records left behind by a killed session are swept as they are found" four lines below. The module docstring documents the single-machine assumption for tokenless platforms; this page does not. — Concrete cost: two Macs sharing one NFS/roaming home — each qwen sessions ps destroys the other's live registration (the victim session vanishes until restart) or lists the other's cwd/name as local. Distinct from the unregisterSession race findings: this is the list-path sweep with no identity to compare, so the sweep's re-read guard confirms the foreign record and still unlinks.

Suggested change
Lists the interactive Qwen Code sessions running on this machine right
now. `sessions list` walks saved transcripts ("what have I worked on");
Lists the interactive Qwen Code sessions running on this machine right
now. (On macOS and Windows, where no process start token is available,
machine separation is best-effort when the home directory is shared
across machines.) `sessions list` walks saved transcripts ("what have I worked on");
中文说明

[Suggestion] 文档承诺的"本机列表"与"只清扫已死记录"在无启动令牌(tokenless)平台、且 home 目录跨机器共享时无法兑现:macOS/Windows 上 procStartpidNs 均为 null,于是 listLiveSessions 中命名空间检查通过(null === null)、boot-id 守卫空转(recordBootId 推出为 null)、isSameProcess 退化为裸 kill(pid, 0)。已针对 tokenless 平台读取方的模型探针验证:一条机器 A 形态的记录(pidNs: null, procStart: null)会被机器 B 的清扫 unlinkrecordSurvives=false);换成拥有身份可比对的 Linux 读取方的翻转臂则记录存活;若本机恰好有同 PID 存活进程,外部会话反而被当作本机会话列出(带着它的 cwd/name)。两种结果都与"on this machine right now"及下方第四行"Records left behind by a killed session are swept as they are found"矛盾。模块 docstring 已为 tokenless 平台声明单机假设;本页面没有。具体代价:两台共享同一 NFS/漫游 home 的 Mac——每次 qwen sessions ps 都会摧毁对方的存活注册(受害会话在重启前消失),或把对方的 cwd/name 当成本机会话列出。与 unregisterSession 的竞态发现不同:这是枚举清扫路径上没有身份可比对的情形,清扫的 re-read 守卫会确认该外部记录然后照样 unlink。

— qwen3.8-max via Qwen Code /review (v0.21.11)

this walks the live-process registry ("what is running at this moment").
Records left behind by a killed session are swept as they are found.
Headless sessions (`qwen -p`) do not register with the live-process
registry, so they are not shown.

**Flags:**

| Flag | Type | Default | Description |
| -------- | ------- | ------- | ----------------------------------------------- |
| `--json` | boolean | `false` | Output as JSON Lines (one JSON object per line) |

**Human-readable output (default):**

A table with columns: NAME, PID, AGE, DIRECTORY.

**JSON output (`--json`):**

Outputs JSON Lines on stdout, newest session first. Each line is a JSON
object with fields:

```
schemaVersion, pid, procStart, pidNs, sessionId, cwd, name, startedAt,
qwenVersion
```

Nothing else is written to stdout — an empty listing prints nothing at
all — so `qwen sessions ps --json | jq .` is safe to script against.

JSON output is raw data: field values are emitted exactly as recorded,
with no terminal sanitization. Treat them as data, and sanitize before
rendering them in a terminal.

**Examples:**

```bash
# Show the other live sessions
qwen sessions ps

# Which directories are busy right now?
# Note: `jq -r` renders the raw recorded value in your terminal (see the
# raw-data note above); pipe through a sanitizer if the path is untrusted.
qwen sessions ps --json | jq -r .cwd
```
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 interactive 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
241 changes: 241 additions & 0 deletions packages/cli/src/commands/sessions/ps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import stringWidth from 'string-width';
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, NAME_COL, PID_COL, AGE_COL } = await import(
'./ps.js'
);

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

it('changes unit exactly at the boundary, never one step late', () => {
expect(formatAge(59_999)).toBe('59s');
expect(formatAge(60_000)).toBe('1m');
expect(formatAge(3_599_000)).toBe('59m');
expect(formatAge(3_600_000)).toBe('1h');
expect(formatAge(24 * 3_600_000 - 1_000)).toBe('23h');
expect(formatAge(24 * 3_600_000)).toBe('1d');
});
});

describe('qwen sessions ps', () => {
it('prints a table of live sessions', async () => {
listLiveSessions.mockResolvedValue([record()]);
await run({ json: 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('puts every column at its declared offset', async () => {
// `toContain` cannot tell a laid-out table from four values joined by
// one space, and it cannot see the age at all. Pin the whole row.
vi.useFakeTimers();
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
listLiveSessions.mockResolvedValue([
record({ startedAt: Date.now() - 5_000 }),
]);
await run({ json: false });
} finally {
vi.useRealTimers();
}

expect(stdout[0]).toBe(
'NAME'.padEnd(NAME_COL) +
'PID'.padEnd(PID_COL) +
'AGE'.padEnd(AGE_COL) +
'DIRECTORY',
);
expect(stdout[1]).toBe(
'app-ab'.padEnd(NAME_COL) +
'4242'.padEnd(PID_COL) +
'5s'.padEnd(AGE_COL) +
'/w/app',
);
expect([NAME_COL, PID_COL, AGE_COL]).toEqual([22, 9, 10]);
});

it('says so plainly when nothing else is running', async () => {
listLiveSessions.mockResolvedValue([]);
await run({ json: false });
expect(stdout).toEqual([
'No other interactive 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 });

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

it('emits each record as one whole line of JSON Lines', async () => {
// JSON Lines is line-delimited by definition: a pretty-printed record
// still round-trips through JSON.parse but breaks every consumer that
// reads it a line at a time, and drops no field on the way.
const rec = record();
// Snapshotted before the run: the mock hands the handler the object
// itself, so computing the expectation afterwards would observe the
// very object the handler (mutatingly) emitted and could never catch
// an in-place field deletion.
const expected = JSON.stringify(rec);
listLiveSessions.mockResolvedValue([rec]);
await run({ json: true });

expect(stdout).toEqual([expected]);
expect(stdout[0]).not.toContain('\n');
});

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

it('neutralizes control sequences coming from another process record', async () => {
listLiveSessions.mockResolvedValue([
Comment on lines +163 to +164

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] This test embeds raw invisible 0x1B (ESC) bytes in the source — one in the fixture string (line 165) and one in the assertion (line 170) — instead of the escaped form the sibling list.test.ts uses for the identical behavior — Failure scenario: the bytes are invisible in every review/diff rendering (this review's own tooling truncates the moment it serializes them); if an editor, paste, or tooling pass drops the invisible byte, the fixture loses its CSI sequence and the test still passes while no longer exercising ESC neutralization at all — silent coverage loss of exactly the security behavior this PR documents. Prettier does not normalize string-literal content, so nothing in CI catches it. Suggested fix: replace the two raw bytes with escape sequences, as the adjacent bidi test and list.test.ts already do (verified behavior-preserving: 16/16 tests pass before and after).

中文说明

该测试在源码中嵌入了原始的不可见 0x1B(ESC)字节——fixture 字符串(第 165 行)一个、断言(第 170 行)一个——而没有像同目录 list.test.ts 在相同行为上使用转义形式。失败场景:这些字节在任何评审/diff 渲染中都不可见(本次评审的工具链在序列化它们的瞬间就会截断);若编辑器、粘贴或某个工具链环节丢弃了该不可见字节,fixture 将不再含 CSI 序列,而测试仍然通过,却完全不再检验 ESC 中和——恰恰对本 PR 文档化的安全行为造成静默覆盖丢失。Prettier 不会规范化字符串字面量内容,CI 中没有任何环节能捕获。建议修复:把两个原始字节替换为转义序列(与相邻 bidi 测试及 list.test.ts 一致);已验证行为保持不变(前后均 16/16 通过)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

record({ name: 'ev\x1b[31mil\r', cwd: '/w/a\nb\tc' }),
]);
await run({ json: false });

const row = stdout[1];
expect(row).not.toContain('\x1b');
expect(row).not.toContain('\r');
expect(row).not.toContain('\n');
Comment on lines +171 to +172

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 TAB half of ps.ts's cell sanitizer (sanitizeTerminalText(value).replace(/[\t\n]/g, '')) is exercised by no test: sanitizeTerminalText deliberately preserves TAB, so that one regex clause is the only thing keeping TAB out of a one-line table cell, and this fixture contains ESC/CR/LF/bidi but no TAB — Failure scenario: measured mutant — .replace(/[\n]/g, '') keeps the suite green at 15/15, yet a live session whose cwd contains a literal TAB (legal in POSIX filenames, and these fields are written by another process) then renders a real TAB in qwen sessions ps, expanding to the next tab stop and misaligning AGE/DIRECTORY for that row — the exact breakage the clause exists to prevent. Extend the fixture and the assertions together:

record({ name: 'ev\u001b[31mil\r', cwd: '/w/a\nb\tc' }),
// ...
expect(row).not.toContain('\t');

— qwen3.8-max via Qwen Code /review (v0.21.10)

// sanitizeTerminalText deliberately preserves TAB for multi-line
// render sites; the one-line table cell drops it on top — a literal
// TAB in a cwd (legal in POSIX filenames) would otherwise expand to
// the next tab stop and misalign every column after AGE.
expect(row).not.toContain('\t');
});

it('strips bidi overrides that would reorder the rendered row', async () => {
listLiveSessions.mockResolvedValue([
record({ name: 'a\u202Eb', cwd: '/w/\u202Dsafe\u2069' }),
]);
await run({ json: false });

expect(stdout[1]).not.toMatch(/[\u202A-\u202E\u2066-\u2069]/);
expect(stdout[1]).toContain('/w/safe');
});

it('emits --json values raw, leaving terminal sanitization to the consumer', async () => {
// The contract the docs state: JSON output is data, not display.
// Bidi overrides that the table path strips must round-trip here —
// sanitizing them would rewrite the recorded path for every tooling
// consumer and diverge from the sibling `sessions list --json`.
listLiveSessions.mockResolvedValue([record({ cwd: '/w/\u202Ereorder' })]);
await run({ json: true });

expect(JSON.parse(stdout[0]).cwd).toBe('/w/\u202Ereorder');
});

it('truncates an over-long name instead of breaking the columns', async () => {
listLiveSessions.mockResolvedValue([record({ name: 'x'.repeat(80) })]);
await run({ json: false });
expect(stdout[1]).toContain('\u2026');
expect(stdout[1]).toContain('4242');
});

it('truncates the name two cells short of its column, leaving a gutter', async () => {
// The gutter is what keeps a maximally long name from touching the PID
// beside it; truncating to the full column width would remove it.
listLiveSessions.mockResolvedValue([record({ name: 'x'.repeat(80) })]);
await run({ json: false });

expect(stdout[1].slice(0, NAME_COL)).toBe(`${'x'.repeat(19)}\u2026 `);
});

it('declares --json as a boolean that is off by default', async () => {
const options: Record<string, unknown> = {};
const yargs = {
option: vi.fn((key: string, config: unknown) => {
options[key] = config;
return yargs;
}),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(psCommand.builder as any)(yargs);

expect(psCommand.command).toBe('ps');
expect(options['json']).toMatchObject({ type: 'boolean', default: false });
});

it('keeps a CJK name inside its column instead of shifting the row', async () => {
listLiveSessions.mockResolvedValue([record({ name: '项目'.repeat(20) })]);
await run({ json: false });

// Padding is measured in terminal cells, not code units: a 2-cell CJK
// character must not push the PID column one cell right per character.
const row = stdout[1];
expect(stringWidth(row.slice(0, row.indexOf('4242')))).toBe(22);
});
});
Loading
Loading