Repository navigation
feat(core): add a live-session registry and qwen sessions ps
#8728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
b92fde1
88cb405
a9e9cee
0f0af41
dc42240
552dfbb
d62d196
c1f7813
421774f
2423dcf
9c16eb1
8621320
a5bac99
f9f3ddc
124a96f
040fad2
01d9d6a
36b987a
65661f0
375bb56
55cf254
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: 'ev[31mil\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'); | ||
| }); | ||
| }); |
| 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) + | ||||||||||||||||||
| 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 }); | ||||||||||||||||||
|
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, | ||||||||||||||||||
| }) | ||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The real yargs surface of the new
Suggested change
中文说明新命令 — DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Partially addressed, and I am leaving this open rather than claiming it.
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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||||||||||||||||||
| }, | ||||||||||||||||||
| }; | ||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.