Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6eb41f0
feat(cli): see, answer and stop a background session
yiliang114 Sep 3, 2026
cb77045
Merge branch 'feat/agent-view-bg-dispatch' into feat/agent-view-sessi…
yiliang114 Sep 3, 2026
2031747
fix(cli): strip LF/TAB from one-line session control output
yiliang114 Sep 3, 2026
6c194f9
fix(cli): name peek's title the way sessions ps does
yiliang114 Sep 4, 2026
572de7a
fix(cli): stop peek printing the summary twice
yiliang114 Sep 4, 2026
2fde009
fix(cli): write session control failures to stderr
yiliang114 Sep 4, 2026
7614b86
Merge branch 'feat/agent-view-bg-dispatch' into feat/agent-view-sessi…
yiliang114 Sep 4, 2026
04735cc
Merge branch 'feat/agent-view-bg-dispatch' into feat/agent-view-sessi…
yiliang114 Sep 4, 2026
8648302
fix(cli): describe the id surfaces peek/answer/stop take accurately
yiliang114 Sep 4, 2026
5a3fcd6
fix(cli): word the answer-hint gate after the supervisor's refusal
yiliang114 Sep 4, 2026
6ab9f75
test(cli): pin the unexercised guard branches in managed-control
yiliang114 Sep 4, 2026
aa225d8
refactor(cli): share the one-line terminal sanitization recipe
yiliang114 Sep 4, 2026
0577418
fix(cli): word the no-supervisor report after what the code knows
yiliang114 Sep 4, 2026
cbebd09
fix(cli): dedupe the peek summary against the title it was derived from
yiliang114 Sep 4, 2026
2ce1526
test(cli): pin peek's Doing/Last lines and the answer success message
yiliang114 Sep 4, 2026
c8a2c6a
fix(cli): deliver dash-leading answers to sessions answer
yiliang114 Sep 4, 2026
88232ff
refactor(cli): give the roster-session matching rule one owner
yiliang114 Sep 4, 2026
82d5cce
fix(cli): offer the answer hint only when the supervisor would accept it
yiliang114 Sep 4, 2026
a87d5f3
test(cli): pin the producer side of the peek response
yiliang114 Sep 4, 2026
3dfe605
fix(cli): type the verbatim tail fold in sessions answer
yiliang114 Sep 4, 2026
4936054
fix(cli): keep root globals out of sessions answer text
yiliang114 Sep 5, 2026
17b1bef
fix(cli): forget the version flag in sessions answer text
yiliang114 Sep 5, 2026
195fa46
fix(cli): take the sessions answer text from the raw argv tail
yiliang114 Sep 5, 2026
781eea1
fix(cli): deliver help/version tokens in the sessions answer text
yiliang114 Sep 8, 2026
f480b3f
Merge feat/agent-view-bg-dispatch into feat/agent-view-session-control
yiliang114 Sep 8, 2026
4a3d424
fix(cli): anchor the sessions answer chain on the token run, not an i…
yiliang114 Sep 12, 2026
09dede7
fix(cli): gate soft-question answerability on the prompt path's refusals
yiliang114 Sep 19, 2026
333bdd5
Merge remote-tracking branch 'origin/main' into pr-10949-sweep
yiliang114 Sep 19, 2026
4399ecc
Merge origin/main into feat/agent-view-session-control
yiliang114 Sep 22, 2026
494a0d9
Merge origin/feat/agent-view-bg-dispatch into feat/agent-view-session…
yiliang114 Sep 22, 2026
8d0217f
Merge the base branch feat/agent-view-bg-dispatch into feat/agent-vie…
yiliang114 Sep 25, 2026
15ce9f7
Merge the base branch feat/agent-view-bg-dispatch into feat/agent-vie…
yiliang114 Sep 25, 2026
c2bc047
fix(cli): strip only a leading answer separator, never a literal one
yiliang114 Sep 25, 2026
02da674
fix(cli): guard sessions peek|answer|stop output against a broken pipe
yiliang114 Sep 26, 2026
214e554
chore(cli): merge feat/agent-view-bg-dispatch into session control
qwen-code-dev-bot Sep 26, 2026
fabf1f4
fix(cli): restore the managed-rows module the base merge dropped
yiliang114 Sep 26, 2026
accae4d
fix(cli): close the sessions-control review round
yiliang114 Oct 1, 2026
3b58036
fix(cli): close the sessions-control review round on the recognizer
yiliang114 Oct 3, 2026
d70279a
Merge feat/agent-view-bg-dispatch into feat/agent-view-session-control
yiliang114 Oct 3, 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
38 changes: 33 additions & 5 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,10 +707,13 @@ 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` |
| `qwen sessions ps` | List the sessions running right now | `qwen sessions ps`, `qwen sessions ps --json` |
| Command | Description | Usage Examples |
| ---------------------- | --------------------------------------------- | ------------------------------------------------------------ |
| `qwen sessions list` | List recent conversation sessions | `qwen sessions list`, `qwen sessions list --json --limit 50` |
| `qwen sessions ps` | List the sessions running right now | `qwen sessions ps`, `qwen sessions ps --json` |
| `qwen sessions peek` | Show what a background session is doing | `qwen sessions peek 0f8e1c42` |
| `qwen sessions answer` | Answer a background session waiting for input | `qwen sessions answer 0f8e1c42 "yes, go ahead"` |
| `qwen sessions stop` | Stop a background session | `qwen sessions stop 0f8e1c42` |

#### `qwen sessions list`

Expand Down Expand Up @@ -762,7 +765,7 @@ qwen --bg "find out why the release job is flaky"
# See it with: qwen sessions ps
```

What it does not do yet: there is no way to attach to a background session, read its transcript, answer its question from the CLI, or stop it. Those land with the Agent View roster.
See what it is doing with `qwen sessions peek`, reply to it with `qwen sessions answer`, and end it with `qwen sessions stop`. What is still missing: attaching to a background session's terminal and reading its full transcript. Those land with the Agent View roster.
Comment thread
yiliang114 marked this conversation as resolved.
Resolved

#### `qwen sessions ps`

Expand Down Expand Up @@ -848,6 +851,31 @@ qwen sessions ps --json | jq -r .cwd
qwen sessions ps --json | jq -r 'select(.taskState == "waiting") | .name'
```

#### `qwen sessions peek|answer|stop <session>`

A background session started with `--bg` runs with nobody watching it. These three commands are how you catch up with one. Each takes a session id or any unique prefix of one — the full id `--bg` prints at launch is enough, and `qwen sessions ps --json` lists it as `sessionId`.

`peek` shows what the session is doing, and what it has stopped to ask:

```bash
$ qwen sessions peek 0f8e1c42
find out why the release job is flaky [0f8e1c42]
State: waiting
Directory: /w/app
Waiting: permission to write scripts/flake-report.md

Answer it with: qwen sessions answer 0f8e1c42 "<your answer>"
```

`answer` replies to a session that is waiting, and `stop` ends one, leaving its transcript in place:

```bash
qwen sessions answer 0f8e1c42 "yes, write the report"
qwen sessions stop 0f8e1c42
```

All three talk to a supervisor that is already running and never start one: if none is, they say so rather than spawning a process to report that nothing exists.

## 6. Messaging Another Running Session

Two interactive sessions on the same machine can send each other
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/agent-view/supervisor-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ import {
patchAgentViewSessionStateIf,
readAgentViewLaunch,
readAgentViewActivity,
readAgentViewRoster,
readAgentViewRosterStrict,
readAgentViewSessionState,
readAgentViewWorker,
redactAgentViewActivity,
redactAgentViewLaunch,
redactAgentViewWorker,
removeAgentViewRosterEntry,
sanitizeSessionId,
Expand Down Expand Up @@ -1109,10 +1111,24 @@ class AgentViewSupervisorProcessHandler
if (activity !== storedActivity) {
this.notifyChanged();
}
// The same join `listAgentViewSessionSnapshots` does for `sessions
// ps`: the title derives from the roster entry, the summary and the
// launch prompt in that order, so peek and ps cannot name the same
// session two different ways. The roster read is the soft-fail one —
// peek reports a session rather than making safety decisions on the
// entry, so an unreadable roster degrades to the untitled placeholder
// instead of failing the whole read.
const roster = await readAgentViewRoster(store);
return {
sessionId,
state,
activity: redactAgentViewActivity(activity),
rosterEntry: roster.sessions.find(
(entry) => sanitizeSessionId(entry.sessionId) === sessionId,
),
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
launch: redactAgentViewLaunch(
await readAgentViewLaunch(sessionId, store),
),
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
worker: redactAgentViewWorker(
await readAgentViewWorker(sessionId, store),
),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/agent-view/supervisor-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ function normalizeLaunch(
}) as AgentViewLaunchFile;
}

function redactAgentViewLaunch(
export function redactAgentViewLaunch(
launch: AgentViewLaunchFile | undefined,
): AgentViewLaunchFile | undefined {
if (!launch) return undefined;
Expand Down
24 changes: 19 additions & 5 deletions packages/cli/src/commands/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ vi.mock('./sessions/list.js', () => ({
vi.mock('./sessions/ps.js', () => ({
psCommand: {
command: 'ps',
describe: 'List interactive Qwen Code sessions running right now',
describe: 'List the Qwen Code sessions running right now',
},
}));

vi.mock('./sessions/control-commands.js', () => ({
peekCommand: { command: 'peek <session>', describe: 'Peek' },
answerCommand: { command: 'answer <session> <text>', describe: 'Answer' },
stopCommand: { command: 'stop <session>', describe: 'Stop' },
}));

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

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

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

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

expect(commandNames).toContain('list');
expect(commandNames).toContain('ps');
// Reading a background session's question, answering it and stopping
// it are what make the `needs input` state `ps` reports actionable;
// losing one of them silently would strand the user there.
expect(commandNames).toEqual([
'list',
'ps',
'peek <session>',
'answer <session> <text>',
'stop <session>',
]);

expect(mockYargs.demandCommand).toHaveBeenCalledWith(
1,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import type { CommandModule, Argv } from 'yargs';
import { listCommand } from './sessions/list.js';
import { psCommand } from './sessions/ps.js';
import {
answerCommand,
peekCommand,
stopCommand,
} from './sessions/control-commands.js';

export const sessionsCommand: CommandModule = {
command: 'sessions',
Expand All @@ -15,6 +20,9 @@ export const sessionsCommand: CommandModule = {
yargs
.command(listCommand)
.command(psCommand)
.command(peekCommand)
.command(answerCommand)
.command(stopCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
// demandCommand(1) ensures a subcommand is always required;
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/commands/sessions/control-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

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

const connectExistingAgentViewSupervisor = vi.fn();

vi.mock('../../agent-view/supervisor-runner.js', () => ({
connectExistingAgentViewSupervisor: (...args: unknown[]) =>
connectExistingAgentViewSupervisor(...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 { answerCommand, peekCommand } = await import('./control-commands.js');

const SESSION = '0f8e1c42-9d3a-4d21-8f77-2b6a7c9e0c31';

let savedExitCode: typeof process.exitCode;

beforeEach(() => {
stdout.length = 0;
stderr.length = 0;
savedExitCode = process.exitCode;
process.exitCode = undefined;
connectExistingAgentViewSupervisor.mockReset();
});

afterEach(() => {
process.exitCode = savedExitCode;
});

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

describe('session control command reporting', () => {
it('writes a failure to stderr and keeps stdout clean', async () => {
// No supervisor running: peek fails, and the message must not land
// in the success channel of a `qwen sessions peek <id> > last.log`.
connectExistingAgentViewSupervisor.mockResolvedValue(undefined);
await run(peekCommand, { session: SESSION });
expect(stderr.join('\n')).toContain('No background sessions');
expect(stdout).toEqual([]);
expect(process.exitCode).toBe(1);
});

it('writes a refused answer to stderr, not into the result', async () => {
// The shape that bit before the fix: `answer "$id" "$reply" > last.log`
// against a stale id wrote the error into the redirected file, mixed
// with what success prints, distinguishable only by exit code.
connectExistingAgentViewSupervisor.mockResolvedValue({
answer: vi
.fn()
.mockRejectedValue(new Error('No Agent View session found for zz.')),
});
await run(answerCommand, { session: 'zz', text: 'yes' });
expect(stderr).toEqual(['No Agent View session found for zz.']);
expect(stdout).toEqual([]);
expect(process.exitCode).toBe(1);
});

it('writes a delivered answer to stdout', async () => {
connectExistingAgentViewSupervisor.mockResolvedValue({
answer: vi.fn().mockResolvedValue({ sessionId: SESSION, answered: true }),
});
await run(answerCommand, { session: SESSION, text: 'yes' });
expect(stdout).toEqual(['Answer delivered.']);
expect(stderr).toEqual([]);
expect(process.exitCode).toBeUndefined();
});
});
102 changes: 102 additions & 0 deletions packages/cli/src/commands/sessions/control-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

/**
* `qwen sessions peek|answer|stop` — the three things a background
* session needs before `qwen sessions ps` reporting "needs input" means
* anything.
*
* The argv shape is most of what is here; the decisions live in
* `managed-control.ts`. The one thing this file decides on its own is
* which stream a result goes to.
*/

import type { Argv, CommandModule } from 'yargs';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import {
answerManagedSession,
peekManagedSession,
stopManagedSession,
type ConnectSupervisor,
type ManagedControlResult,
} from './managed-control.js';

interface SessionIdArgs {
session: string;
}

interface AnswerArgs extends SessionIdArgs {
text: string;
}

/**
* Connect to a running supervisor, or report that there is none.
*
* Deliberately `connectExisting` rather than `ensure`: these commands ask
* about sessions, and starting a supervisor to answer would spawn a
* process only to be told nothing is running.
*/
const connectSupervisor: ConnectSupervisor = async () => {
const { connectExistingAgentViewSupervisor } = await import(
'../../agent-view/supervisor-runner.js'
);
return connectExistingAgentViewSupervisor();
};

/**
* Print a result, and set the exit code if it failed.
*
* Failure lines go to stderr, the way `list`, `ps` and `--bg` report
* errors; success lines stay on stdout, so redirecting the command's
* output never captures an error message alongside the result.
*/
function report(result: ManagedControlResult): void {
const write = result.exitCode !== 0 ? writeStderrLine : writeStdoutLine;
for (const line of result.lines) write(line);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
if (result.exitCode !== 0) process.exitCode = result.exitCode;
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}

function sessionPositional(yargs: Argv): Argv {
return yargs.positional('session', {
type: 'string',
describe: 'Session id, or a unique prefix of one',
demandOption: true,
});
}

export const peekCommand: CommandModule<unknown, SessionIdArgs> = {
command: 'peek <session>',
describe: 'Show what a background session is doing or waiting for',
builder: (yargs: Argv) => sessionPositional(yargs) as Argv<SessionIdArgs>,
handler: async (argv) => {
report(await peekManagedSession(argv.session, connectSupervisor));
},
};

export const answerCommand: CommandModule<unknown, AnswerArgs> = {
command: 'answer <session> <text>',
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
describe: 'Answer a background session that is waiting for input',
builder: (yargs: Argv) =>
sessionPositional(yargs).positional('text', {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
type: 'string',
describe: 'What to tell it',
demandOption: true,
}) as Argv<AnswerArgs>,
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
handler: async (argv) => {
report(
await answerManagedSession(argv.session, argv.text, connectSupervisor),
);
},
};

export const stopCommand: CommandModule<unknown, SessionIdArgs> = {
command: 'stop <session>',
describe: 'Stop a background session',
builder: (yargs: Argv) => sessionPositional(yargs) as Argv<SessionIdArgs>,
handler: async (argv) => {
report(await stopManagedSession(argv.session, connectSupervisor));
},
};
Loading
Loading