Skip to content
Closed
Show file tree
Hide file tree
Changes from 22 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
44 changes: 39 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,37 @@ 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
```

An answer that starts with a dash would otherwise look like a flag, so take it verbatim after `--` (a bare `--help` still shows the command's help):

```bash
qwen sessions answer 0f8e1c42 -- --force
```

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
85 changes: 85 additions & 0 deletions packages/cli/src/agent-view/supervisor-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,43 @@ describe('Agent View supervisor process helpers', () => {
]),
);

// The producer side of the peek join: the roster rename `sessions
// ps` shows, and the launch record with its env stripped. Without
// these pins, dropping rosterEntry (peek silently stops showing the
// rename) or serving the launch record raw (the QWEN_AGENT_VIEW_TOKEN
// in env leaks into the IPC reply) stays green under the
// toMatchObject assertions above.
await upsertAgentViewRosterEntry(
{
sessionId,
projectCwd: globalDir,
activeCwd: globalDir,
displayName: 'release-debugger',
createdAt: '2026-09-05T00:00:00.000Z',
updatedAt: '2026-09-05T00:00:00.000Z',
},
{ globalDir },
);
const storedLaunch = await readAgentViewLaunch(sessionId, { globalDir });
if (!storedLaunch) throw new Error('expected launch record');
await writeAgentViewLaunch(
{
...storedLaunch,
env: { QWEN_AGENT_VIEW_TOKEN: 'worker-secret' },
},
{ globalDir },
);
await expect(handler.peek?.({ sessionId })).resolves.toMatchObject({
sessionId,
rosterEntry: expect.objectContaining({
sessionId,
displayName: 'release-debugger',
}),
launch: expect.objectContaining({ env: {} }),
// Still starting, not waiting for input.
answerable: false,
});

await fs.rm(globalDir, { recursive: true, force: true });
});

Expand Down Expand Up @@ -1436,6 +1473,54 @@ describe('Agent View supervisor process helpers', () => {
await fs.rm(globalDir, { recursive: true, force: true });
});

it('reports answerability in peek after the supervisor refusal model', async () => {
// peek offers `qwen sessions answer` through the answerable field;
// the verdict must flip exactly when queueAnswerForSessionLocked
// would accept or refuse, not track the waiting state alone.
const globalDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-agent-view-store-'),
);
const handler = createAgentViewSupervisorHandler({
globalDir,
platform: 'linux',
launchPtyHost: async () => fakePtyHost(),
});
const result = (await handler.dispatch?.({
prompt: 'write tests',
cwd: globalDir,
})) as { sessionId: string };
const token = await readWorkerTokenForTest(result.sessionId, globalDir);
await handler.workerEvent?.({
type: 'ready',
sessionId: result.sessionId,
token,
cwd: globalDir,
});
await handler.workerEvent?.({
type: 'state',
sessionId: result.sessionId,
token,
sessionState: 'needs_input',
waitingFor: 'approval',
});

// Waiting, live, nothing pending: an answer would be accepted.
await expect(
handler.peek?.({ sessionId: result.sessionId }),
).resolves.toMatchObject({ answerable: true });

await handler.answer?.({ sessionId: result.sessionId, text: 'yes' });

// The previous answer is still being delivered; a second one is
// refused with "is waiting for the previous response", so peek must
// stop advertising the command.
await expect(
handler.peek?.({ sessionId: result.sessionId }),
).resolves.toMatchObject({ answerable: false });

await fs.rm(globalDir, { recursive: true, force: true });
});

it('clears pending worker controls when a session is killed', async () => {
const globalDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-agent-view-store-'),
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/agent-view/supervisor-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { dispatchAgentViewSession } from './supervisor-dispatch.js';
import {
clearAgentViewWorkerPids,
digestAgentViewWorkerToken,
findAgentViewRosterEntry,
getAgentViewSessionPaths,
getAgentViewStorePaths,
inputKindValue,
Expand All @@ -51,10 +52,12 @@ import {
patchAgentViewSessionStateIf,
readAgentViewLaunch,
readAgentViewActivity,
readAgentViewRoster,
readAgentViewRosterStrict,
readAgentViewSessionState,
readAgentViewWorker,
redactAgentViewActivity,
redactAgentViewLaunch,
redactAgentViewWorker,
removeAgentViewRosterEntry,
sanitizeSessionId,
Expand Down Expand Up @@ -1109,16 +1112,59 @@ 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: findAgentViewRosterEntry(roster, sessionId),
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),
),
live: this.workers.has(sessionId),
answerable: this.isSessionAnswerable(sessionId, state, activity),
};
}

/**
* The refusal model of `queueAnswerForSessionLocked`, read without
* its side effects (no stale-attach detach, no reconnect): peek
* offers `qwen sessions answer` only when following the hint would
* not be refused. Re-deriving this from the waiting state alone
* re-advertises a command that is guaranteed to fail while a
* previous answer is pending, a live attach is open, or no process
* can take a blocking wait.
*/
private isSessionAnswerable(
sessionId: string,
state: AgentViewSessionStateFile,
activity: AgentViewActivityFile | undefined,
): boolean {
if (state.sessionState !== 'needs_input') return false;
// A live attach refuses the answer; a stale attach flag is detached
// by the answer path itself, so it does not block.
if (this.hasLiveAttach(sessionId)) return false;
// The previous answer has not been picked up yet.
if (this.hasPendingWorkerInputControl(sessionId)) return false;
// Soft questions queue through the prompt path, which can reconnect
// or respawn a worker to deliver; a blocking wait needs a live
// process right now.
if (getAgentViewActivityInputState(activity) === 'soft_question') {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
return true;
}
return this.workers.has(sessionId);
}

async send(params?: Record<string, unknown>) {
const sessionId = await resolveManagedSessionId(
requireSessionId(params),
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/agent-view/supervisor-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
findAgentViewRosterEntry,
getAgentViewSessionPaths,
getAgentViewStorePaths,
listAgentViewSessionSnapshots,
Expand All @@ -35,6 +36,7 @@ import type {
AgentViewActivityFile,
AgentViewLaunchFile,
AgentViewRosterEntry,
AgentViewRosterFile,
AgentViewSessionStateFile,
AgentViewSupervisorFile,
AgentViewWorkerFile,
Expand Down Expand Up @@ -572,3 +574,35 @@ function sessionState(
...overrides,
};
}

describe('findAgentViewRosterEntry', () => {
function rosterWith(sessionId: string): AgentViewRosterFile {
return {
schemaVersion: 1,
updatedAt: '2026-09-05T00:00:00.000Z',
sessions: [
{
sessionId,
projectCwd: '/w/app',
activeCwd: '/w/app',
createdAt: '2026-09-05T00:00:00.000Z',
updatedAt: '2026-09-05T00:00:00.000Z',
},
],
};
}

it('sanitizes both sides of the comparison', () => {
// A raw, mixed-case lookup must reach the same entry as its
// canonical form; sanitizing one side only would let the two
// spellings miss each other.
const roster = rosterWith('Session-ABC');
expect(findAgentViewRosterEntry(roster, 'Session-ABC')?.sessionId).toBe(
'Session-ABC',
);
expect(findAgentViewRosterEntry(roster, 'session-abc')?.sessionId).toBe(
'Session-ABC',
);
expect(findAgentViewRosterEntry(roster, 'other')).toBeUndefined();
});
});
24 changes: 19 additions & 5 deletions packages/cli/src/agent-view/supervisor-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,20 +352,34 @@ export async function listAgentViewSessionStates(
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}

/**
* The roster entry for a session, under the store's one matching rule:
* ids compared after `sanitizeSessionId` on both sides. Every surface
* that names "the roster entry for this session" goes through this
* owner of the rule, so `sessions ps` and `sessions peek` cannot name
* the same session two different ways.
*/
export function findAgentViewRosterEntry(
roster: AgentViewRosterFile,
sessionId: string,
): AgentViewRosterEntry | undefined {
const key = sanitizeSessionId(sessionId);
return roster.sessions.find(
(entry) => sanitizeSessionId(entry.sessionId) === key,
);
}

export async function listAgentViewSessionSnapshots(
options: StoreOptions = {},
): Promise<AgentViewSessionSnapshot[]> {
const states = await listAgentViewSessionStates(options);
const roster = await readAgentViewRoster(options);
const rosterEntries = new Map(
roster.sessions.map((entry) => [sanitizeSessionId(entry.sessionId), entry]),
);
const snapshots = await Promise.all(
states.map(async (state) => {
const snapshot = {
sessionId: state.sessionId,
state,
rosterEntry: rosterEntries.get(sanitizeSessionId(state.sessionId)),
rosterEntry: findAgentViewRosterEntry(roster, state.sessionId),
};
if (state.ownership === 'unmanaged') {
return snapshot;
Expand Down Expand Up @@ -817,7 +831,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
Loading
Loading