feat(cli): list managed Agent View sessions in qwen sessions ps (#10942)

* feat(cli): list managed Agent View sessions in qwen sessions ps

`packages/cli/src/agent-view/` is 11,004 production lines with no
consumer: the supervisor, the PTY workers and the session lifecycle all
merged (#7799, #7800, #7801/#9986), while the two PRs that would give
them an entry point are still open (#7802 commands, #7803 roster TUI,
both since 2026-07-27). Nothing calls the subsystem, so nothing that
subsystem knows can reach a user.

This gives it its first one, at the smallest surface that carries real
information: `qwen sessions ps` now lists managed sessions beside the
interactive ones it always listed.

The command walked only the live-process registry, which a supervisor's
worker never writes — so a background session waiting for an answer was
invisible to every listing. `managed-rows.ts` merges both sources into
one row shape, deduplicating by session id (a managed worker can also
register, and would otherwise be listed twice: once as `interactive`,
once with its real state). Managed rows come first, because a session
waiting on input is the usual reason to run this.

Three decisions worth naming:

- Rows are labelled by task state, not by the roster's display group.
  The group folds `ready`, `stopped` and `failed` into one `completed`
  bucket, which the roster UI can afford because it also paints an icon
  tone; a one-line table has no second channel, and printing "completed"
  beside a session that failed is a lie the user cannot see through.
- The name comes from `deriveAgentViewPresentation`, so this listing and
  the roster cannot drift into describing one session two ways. Its
  `Untitled session` placeholder is the single override — the roster can
  afford identical rows because a user arrows onto one; here the session
  id is the only thing that tells two apart. The placeholder is now
  exported rather than duplicated as a literal.
- A supervisor store that cannot be read degrades to the registry half
  and says so on stderr. Silently omitting a session that needs input is
  the failure this command exists to prevent, and stderr keeps `--json`
  stdout parseable.

The table gains a STATE column; `--json` gains a `managed` discriminator
on both row kinds, and carries each registry record through verbatim
rather than looking it back up by session id — two records can share one
(a stale writer, a restored transcript), which would emit one twice and
drop the other.

* docs(skills): stop the recency gate from silencing a stalled wiring

The find-simplifications survey uses `packages/cli/src/agent-view` as its
worked example for "everything says dead, git says five days old": a
feature mid-wiring, so drop it silently — do not file it, do not mention
it.

The deletion verdict was right. The silence was not. Measured again on
2026-09-04, one month after that example was written: 11,004 production
lines, still zero external references, up ~8,000 in four weeks. Its five
merged PRs shipped the supervisor, the PTY workers and the lifecycle;
the two that would give it an entry point have been open since
2026-07-27, the second at +31,699 lines across 104 files and
conflicting. The gate kept firing on the directory's recency and the
survey kept dropping it, so nothing ever said out loud that a
five-figure subsystem had no caller.

"Young" is not the fact the gate is reaching for — "still being wired
up" is, and for a subsystem the two come apart. The gate now says to
date the wiring rather than the code when it fires on a subsystem, and
names the outcome: recent commits inside, no external reference, and an
open stalled wiring PR is neither a deletion nor a drop. It is a
different finding with a different owner, owed to that PR stack.

* fix(cli): keep non-owned supervisor snapshots out of sessions ps

managedSessionRows mapped every snapshot the store returns, but the
store also holds unmanaged tombstones, mid-removal snapshots and
mid-adoption snapshots; an adopting snapshot reuses the id of a live
registered session, so the merge replaced a registry row that knows a
live pid with a pid-less ghost. Filter to ownership === 'managed',
the shape the supervisor's own listing uses.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

* fix(cli): verify managed session pids before listing them

The supervisor store is durable and nothing reaps it when no supervisor
runs, so after a crash or a reboot a managed row could carry a pid that
is dead or recycled to an unrelated process, and acting on it would
signal the wrong target. Check both recorded pids with core's isPidAlive
(worker first, then the PTY host, matching the supervisor's own idiom)
and print `-` when neither lives.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

* test(cli): pin LF and TAB stripping on the ps store-failure note

The reason text is foreign input, and a preserved LF would forge extra
lines out of the single stderr note the sibling test pins. Feed a
message carrying all four classes and assert none survive, so a
downgrade to plain sanitizeTerminalText (which keeps LF and TAB) fails.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

* docs(commands): warn that the jq -r example renders session text raw

The .name example prints session-generated text with jq -r; like the
adjacent .cwd example, it must say that the value is rendered raw and
to sanitize it when untrusted.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

* fix(cli): keep the sessions ps JSON contract off display wording

Review finding 2 on this PR: `SessionRow.state` was documented as "what
the STATE column can say" and went straight into `--json`, so the
machine contract was pinned to display copy. `'needs input'` carries a
space, and the documented recipe was
`jq -r 'select(.state == "needs input")'` — reword the column later and
every script breaks silently, with no type error anywhere to warn.

The stable enum was already in hand. The row now carries
`presentation.taskState` (`running` | `waiting` | `ready` | `stopped` |
`failed`) and `ps.ts` maps it to English at the one place that renders a
table.

This also removes an asymmetry the reviewer named: `state` was a *kind*
discriminator for registry rows (`'interactive'`) and a *task* state for
managed ones — one field with two meanings, which is why `managed` had
to be added beside it. A registry row now has no `taskState` at all,
which is the truth: it knows a process is alive and nothing more.

* fix(cli): import the task-state type the row test annotates

CI caught what a vitest-only run cannot: the previous commit annotated a
test case with `AgentViewTaskState` but never imported it, so
`tsc --build` failed with TS2304 and took the TUI gates down with it on
every branch in the stack. The import edit had targeted an import block
that had since gained another symbol, so the anchor never matched and
the change was silently dropped.

Verified the same way it should have been the first time: every file
that names the type now imports it, and nothing anywhere still refers to
the removed `SessionRowState` or `row.state`.

* fix(cli): hold managed session rows to the registry's identity standard

Both blockers on this listing are the same mistake in two places: the
merge joins two sources that disagree about what identifies a session,
and each half trusted its own spelling.

Pids. `liveWorkerPid` gated durable recorded pids with a bare
`isPidAlive`, and the worker file recorded no process identity at all.
Nothing reaps that file while no supervisor runs — `clearAgentViewWorkerPids`
does not get to run after a SIGKILL or a reboot — so once the OS recycles
the number, `kill(pid, 0)` answers "alive" about an unrelated process and
`qwen sessions ps` prints it next to a row still reading `working`; a
script reading `--json` kills a stranger. A `~/.qwen` shared between
machines or namespaces (an NFS home, a devcontainer with the home
mounted) needs no recycling to reach the same place. The interactive rows
in the very same table are verified with `isSameProcess` plus namespace
and boot-id guards, so the two halves answered to different evidence
standards.

The supervisor now records the start token for each pid it writes, plus
its own PID namespace, and the reader checks `isSameProcess` against
them. All three fields are optional: `AgentViewWorkerFile` is a durable
`schemaVersion: 1` record, and a file written before they existed carries
none, which `isSameProcess` reads as "no identity recorded" and degrades
to exactly the liveness check this command did before. The namespace
guard fires only on a known disagreement, so an unreadable `/proc` on
either side never blanks a real worker's pid.

Ids. `mergeSessionRows` deduped with a case-sensitive `Set.has`, but the
supervisor store reports the sanitized, lowercased directory name it
files a session under while the registry keeps the raw spelling the
worker registered with — adoption keeps both on purpose, because the
native session store is case-sensitive. A managed session whose id
contains an uppercase letter therefore slipped the filter and listed
twice, once with its real state and once as `interactive`, with `--json`
emitting two different spellings for one session. Both sides now
canonicalize through `sanitizeSessionId`.

That sanitizer moved from `supervisor-store` to `protocol`, which is
where it belongs — it defines the identity two readers must agree on, not
a detail of writing files — and it keeps the pure row module free of a
dependency on the filesystem store. `supervisor-store` re-exports it, so
no caller changes.

Tests pin the two arms liveness cannot see: a live pid whose recorded
token no longer matches, and a foreign-namespace worker file. Two more
pin the degradations, since silently blanking every pre-identity pid
would be the worse regression. The mock mirrors the real `isSameProcess`
contract rather than stubbing a verdict, so the existing dead-pid cases
still exercise the fall-through they were written for.

Typecheck, lint and tests were not run locally; CI is the authority on
this branch.

* test(cli): give ps.test.ts the identity helpers its subject now calls

853d57b taught `managedSessionRows` to verify pids with `isSameProcess`
and `readPidNamespaceId`, and updated the core mock in
`managed-rows.test.ts` — but `ps.test.ts` mocks the same module and
reaches the same code through `ps.ts`, and its factory still exported
only `listLiveSessions` and `isPidAlive`.

So `readPidNamespaceId()` was `undefined` at the call site, the TypeError
propagated out of the snapshot mapping, and `ps.ts` caught it on the path
meant for an unreadable supervisor store: every managed row vanished and
the command reported a store failure. Four tests failed in CI with the
managed half of the listing simply missing — `expected [ false ] to
deeply equal [ true, false ]` — rather than with the type error that
caused it.

The mock now mirrors the real `isSameProcess` contract, the same way
`managed-rows.test.ts` does, so the fixtures — which record no start
token — keep degrading to a bare liveness check and
`isPidAlive.mockReturnValue(false)` still means exactly "this pid is
dead".

Worth a follow-up, filed separately rather than widened into this commit:
`ps.ts` treating a TypeError from row mapping as "the store could not be
read" is why this surfaced as four confusing assertion failures instead
of one stack trace.

Typecheck, lint and tests were not run locally; CI is the authority.

* fix(cli): correct sessions ps comment claims and pin every STATE label

- managed-rows.ts: the managedSessionRows rationale block said the
  supervisor's own listing "skips the same shapes", but
  SupervisorProcess.list() only skips unmanaged and removing snapshots
  and shows sessions mid-adoption. State the difference, and why this
  listing must keep filtering adopting snapshots (R2-2).
- ps.test.ts: render the human table for managed sessions in working,
  idle, stopped and failed states and assert the STATE cell against the
  documented labels, so no swap among the four previously unpinned
  TASK_STATE_LABEL values can ship green (R3-1). Also fix the
  managedSnapshot helper, whose trailing spread shadowed the
  constructed state when a caller passed a partial state override.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtmourmvi4

* fix(cli): finish sessions ps review cleanup

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): guard sessions ps managed pids by boot id like the registry

liveWorkerPid verified recorded pids with the start token and the PID
namespace, but omitted the registry's boot-id pre-guard. Two machines
sharing one ~/.qwen both live in the initial PID namespace, whose inode
is a kernel constant, so the namespace guard never fires between them;
when the local boot id is unreadable — or the reader is non-Linux —
isSameProcess degrades to a bare liveness check and a foreign worker
file's pid, routinely a live unrelated local process, was printed.

Read readLocalBootId() once and skip any candidate whose recorded token
carries a boot prefix that differs from the local boot id, mirroring
listLiveSessions: the guard fires on an unreadable local boot id as
well, not only on a positive mismatch, and token-less pre-identity
candidates keep the liveness fall-through.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtnaadh9j8

* fix(cli): report the resumable session id spelling in sessions ps

The managed row emitted the sanitized (lowercased) store id as
sessionId, and mergeSessionRows dropped the deduped registry record
that was the only other carrier of the raw spelling. For any session
whose id contains an uppercase letter — a supported class, e.g.
arena-agent ids — a script piping the emitted id into `qwen --resume`
hit the case-sensitive native store's exact-match lookup and failed.

Emit launch.resumeSessionId when recorded, falling back to the store
id for sessions created rather than adopted; the name fallback follows
so the table shows the id scripts can act on. The dedupe comparison
stays sanitized on both sides and store keying is untouched — only the
reported value changes.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtnaadh9j8

* fix(cli): carry a deduped record's spelling and pid onto a degraded managed row

The store fails soft on any read or parse error, so a managed row can be
degraded to the sanitized store id and no pid while the session is still
live, and the registry record the merge dedupes against it may be the only
other carrier of what it lost. Carry the record's raw spelling when the row
fell back to the sanitized id, and its verified pid when the row has none,
so `qwen sessions ps` lists a resumable id and a live pid instead of a
sanitized id no consumer can resume and `-`.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmtnn5c4cjw

* fix(cli): refuse managed pids from a worker file written on another OS

R6-1. The token-less fall-through in `liveWorkerPid` was written for one
class of file — same-machine, written before the identity fields existed,
where a liveness probe was the prior behaviour in the reader's own pid
space. It does not transfer to a file written on another OS, and darwin
and win32 produce exactly that: `readProcStartToken` and
`readPidNamespaceId` return null there permanently, so the file carries
no token and no namespace, clears the namespace and boot-id guards on
those nulls, and reaches a bare `kill(pid, 0)` against a number that
belongs to another OS's pid space.

The shared-`~/.qwen` topology this module's header already names is the
concrete shape: a macOS supervisor's home mounted into a Linux
devcontainer. If any local process holds that number the row prints it in
the table and in `--json`, and a script acting on it hits a stranger.
That is the same "two halves of one table, two evidence standards" defect
this listing was already being fixed for — `listLiveSessions` skips a
foreign-namespace record outright — so it is the same fix, one class
further out.

Pre-platform files keep the documented degradation: `platformValue`
defaults an absent `platform` to `process.platform`
(supervisor-store.ts:1008-1011), so they read as same-platform.

Both test fixtures pinned `platform: 'linux'`. Left alone that would not
have tested the guard, it would have blanked every managed pid on the
macOS runner and failed the suites there instead. Both now use
`process.platform`, and the new case picks a platform that is definitely
not the reader's, asserting the pid is dropped while `isPidAlive` still
reports it live — so the case fails for the right reason and goes red if
the guard is removed.

Typecheck, lint and tests were not run locally; CI is the authority.

* docs: normalize the sessions table padding prettier asks for

The merge commit 7c3e0958 resolved the Session Management table by hand
and kept main's column width, which was sized for the longer "List
interactive sessions" wording. Prettier re-pads the column to the longest
remaining cell, so the Lint & Static job failed at Run Prettier with
`Code style issues found in 1 file`.

Whitespace only: the five changed lines are the table's padding, no cell
text changes. Verified with prettier 3.6.1 --check (the version this
branch's lockfile pins).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtrcr08gpe

* fix(cli): validate resumeSessionId before it becomes a row identity

normalizeLaunch passed resumeSessionId through the raw spread
unvalidated while this PR promoted it to the row's reported sessionId
and the managedIds dedupe key. A non-string spelling crashed ps via
sanitizeSessionId outside every guard; an empty spelling canonicalized
to the catch-all key and could delete a stranger's registry row. Run it
through stringValue like every other text field, and let the consumer
fall back on the empty spelling too.

* fix(cli): import process-liveness and session-registry symbols from their core modules

The core package root is banned as a value-import specifier in packages/cli/src
(eslint.config.js, #10908); the two files added by this PR were not grandfathered,
so Lint & Static failed with 2 no-restricted-imports errors. Import paths only,
no behavior change.

managed-rows.test.ts mocked the barrel specifier, so it stopped intercepting the
deep import and 7 cases leaked the real pid; the mock specifier follows the import.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu8068335i

* test(cli): intercept the deep process-liveness module in the sessions ps suite

The previous commit moved managed-rows.ts off the core package root, so this
suite's barrel-only vi.mock stopped intercepting isSameProcess / readLocalBootId /
readPidNamespaceId, and 2 managed-row cases fell through to the real liveness
probe (pid rendered as '-'). Register the same fakes under the specifier
managed-rows.ts actually imports.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu8068335i

* fix(cli): carry the deduped record's name and cwd, and gate resumeSessionId on identity

* docs(skills): key the stalled-wiring probe on the directory basename

* chore: remove unrelated stalled-wiring guidance

Keep the sessions listing change focused on its user-facing contract instead
of coupling it to an unrelated review-process policy.

* fix(cli): narrow managed session listing

* fix(cli): preserve sessions ps output on stderr failures

* test(cli): make the sessions ps guard-order case able to fail

The case named for the output-error guard asserted from inside the
listAgentViewSessionStates mock. readManagedSessions catches everything
the store throws, so that AssertionError rode out on the caught
rejection and was swallowed: deleting ignoreBrokenPipe() from handlePs
left the file at 24/24 green.

Record the call order in the two mocks and assert it from the test body
instead. Measured: guard deleted -> 1 failed / 23 passed with
`expected [ 'store' ] to deeply equal [ 'guard', 'store' ]`; guard
present -> 24 passed. No production code changed.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmudwd45z01

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-09-25 04:46:40 +00:00
1 parent 010941d202
commit 78faaec8a1
3 files changed
+176 -29

No files matched your search

+22 -10
View File
@@ -761,11 +761,13 @@ qwen sessions list --json | jq .
#### `qwen sessions ps`
Lists the Qwen Code sessions registered on this machine right now.
`sessions list` walks saved transcripts ("what have I worked on"); 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. A
one-shot `qwen -p` run never registers, so it is never shown.
Lists the Qwen Code session records visible on this machine.
`sessions list` walks saved transcripts ("what have I worked on"). This
command shows both the live-process registry and Agent View records marked
`managed`. Records left behind by a killed session are swept from the registry
as they are found. A managed record is persistent bookkeeping, not evidence
that its worker is still alive. A one-shot `qwen -p` run never registers and
is not shown.
**Flags:**
@@ -785,22 +787,32 @@ program drives. Several `serve` or `headless` rows can share one PID: a
the daemon spawned it, `headless` when a client is driving it directly —
and each of them registers separately. It is a self-report, like NAME and DIRECTORY: every field
here was written by the process it describes, and nothing about what a
session is allowed to do depends on it. See
session is allowed to do depends on it. An Agent View record marked
`managed` uses `managed` for KIND and `-` for PID and AGE. It remains a
separate row when the same session also has a live registry record. See
[Cross-Session Protocol](./cross-session-protocol.md) for the record
format and for how to register a program of your own.
**JSON output (`--json`):**
Outputs JSON Lines on stdout, newest session first. Each line is a JSON
object with fields:
Outputs JSON Lines on stdout with managed records first. Registry records
keep their existing shape:
```
schemaVersion, pid, procStart, pidNs, sessionId, cwd, name, startedAt,
qwenVersion, kind, ipcPath (when peer messaging is available)
```
A managed record contains only source-backed data:
```
sessionId, cwd, managed: true
```
Nothing else is written to stdout — an empty listing prints nothing at
all — so `qwen sessions ps --json | jq .` is safe to script against.
all — so `qwen sessions ps --json | jq .` is safe to script against. If
the supervisor store cannot be read, registry records still print and the
reason is written to stderr.
JSON output is raw data: field values are emitted exactly as recorded,
with no terminal sanitization. Treat them as data, and sanitize before
@@ -812,7 +824,7 @@ rendering them in a terminal.
# Show the other live sessions
qwen sessions ps
# Which directories are busy right now?
# Which directories have a visible session record?
# 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
+94 -1
View File
@@ -9,6 +9,8 @@ import stringWidth from 'string-width';
import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core';
const listLiveSessions = vi.fn();
const listAgentViewSessionStates = vi.fn();
const ignoreBrokenPipe = vi.fn();
vi.mock('@qwen-code/qwen-code-core', () => ({
listLiveSessions: (...args: unknown[]) => listLiveSessions(...args),
@@ -22,10 +24,16 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
kind === undefined || kind.length === 0 ? 'tui' : kind,
}));
vi.mock('../../agent-view/supervisor-store.js', () => ({
listAgentViewSessionStates: (...args: unknown[]) =>
listAgentViewSessionStates(...args),
}));
const stdout: string[] = [];
const stderr: string[] = [];
vi.mock('../../utils/stdioHelpers.js', () => ({
ignoreBrokenPipe,
writeStdoutLine: (line: string) => stdout.push(line),
writeStderrLine: (line: string) => stderr.push(line),
}));
@@ -50,6 +58,17 @@ function record(
};
}
function managedState(
over: { ownership?: string; sessionId?: string; cwd?: string } = {},
): Record<string, unknown> {
const {
ownership = 'managed',
sessionId = 'managed-1',
cwd = '/w/svc',
} = over;
return { ownership, sessionId, activeCwd: cwd };
}
async function run(argv: Record<string, unknown>): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (psCommand.handler as any)(argv);
@@ -59,6 +78,9 @@ beforeEach(() => {
stdout.length = 0;
stderr.length = 0;
listLiveSessions.mockReset();
listAgentViewSessionStates.mockReset();
ignoreBrokenPipe.mockReset();
listAgentViewSessionStates.mockResolvedValue([]);
});
afterEach(() => {
@@ -155,7 +177,31 @@ describe('qwen sessions ps', () => {
it('says so plainly when nothing is registered', async () => {
listLiveSessions.mockResolvedValue([]);
await run({ json: false });
expect(stdout).toEqual(['No Qwen Code sessions are registered right now.']);
expect(stdout).toEqual([
'No Qwen Code sessions are registered or managed right now.',
]);
});
it('lists managed records without claiming process liveness', async () => {
listLiveSessions.mockResolvedValue([]);
listAgentViewSessionStates.mockResolvedValue([
managedState(),
managedState({ ownership: 'adopting', sessionId: 'adopting-1' }),
]);
await run({ json: false });
expect(stdout).toEqual([
'NAME'.padEnd(NAME_COL) +
'KIND'.padEnd(KIND_COL) +
'PID'.padEnd(PID_COL) +
'AGE'.padEnd(AGE_COL) +
'DIRECTORY',
'managed-1'.padEnd(NAME_COL) +
'managed'.padEnd(KIND_COL) +
'-'.padEnd(PID_COL) +
'-'.padEnd(AGE_COL) +
'/w/svc',
]);
});
it('emits one JSON object per line with no header', async () => {
@@ -167,6 +213,41 @@ describe('qwen sessions ps', () => {
expect(JSON.parse(stdout[1]).pid).toBe(7);
});
it('installs the output error guard before reading the managed store', async () => {
// Both mocks only record that they ran; every expectation is raised
// afterwards, in the test body. An expectation thrown from inside a
// mock implementation rides out on the rejection that
// `readManagedSessions` deliberately catches, so it is swallowed
// along with the error and the case passes with the guard deleted.
const rec = record();
const order: string[] = [];
ignoreBrokenPipe.mockImplementation(() => {
order.push('guard');
});
listLiveSessions.mockResolvedValue([rec]);
listAgentViewSessionStates.mockImplementation(() => {
order.push('store');
return Promise.reject(new Error('broken store'));
});
await run({ json: true });
expect(order).toEqual(['guard', 'store']);
expect(stdout).toEqual([JSON.stringify(rec)]);
});
it('keeps registry JSON available when the supervisor store cannot be read', async () => {
const rec = record();
listLiveSessions.mockResolvedValue([rec]);
listAgentViewSessionStates.mockRejectedValue(new Error('broken\n\tstore'));
await run({ json: true });
expect(stdout).toEqual([JSON.stringify(rec)]);
expect(stderr).toEqual([
'Managed sessions could not be listed: brokenstore',
]);
});
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
@@ -184,6 +265,18 @@ describe('qwen sessions ps', () => {
expect(stdout[0]).not.toContain('\n');
});
it('keeps managed and registry records separate in JSON', async () => {
const rec = record({ sessionId: 'managed-1' });
listLiveSessions.mockResolvedValue([rec]);
listAgentViewSessionStates.mockResolvedValue([managedState()]);
await run({ json: true });
expect(stdout.map((line) => JSON.parse(line))).toEqual([
{ sessionId: 'managed-1', cwd: '/w/svc', managed: true },
rec,
]);
});
it('carries the kind into the JSON output, unfiltered', async () => {
// This is the surface an aggregator reads to tell a user's own
// terminals from sessions something else is driving, so the field has
+60 -18
View File
@@ -5,17 +5,11 @@
*/
/**
* `qwen sessions ps` — list the Qwen Code sessions running right now.
* `qwen sessions ps` — list registered and managed Qwen Code sessions.
*
* 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".
*
* KIND says what registered each one — an interactive terminal, a
* daemon-managed session, a program that is not Qwen Code at all. It is a
* self-report, like NAME and DIRECTORY: everything here was written by
* the process it describes. What does not appear at all is a one-shot
* `qwen -p` run, which never registers.
* The sibling `qwen sessions list` walks saved transcripts. This command shows
* live registry records and Agent View records marked `managed`. The latter are
* persistent records, not a liveness claim.
*/
import type { CommandModule, Argv } from 'yargs';
@@ -25,11 +19,16 @@ import {
type SessionRegistryRecord,
} from '@qwen-code/qwen-code-core';
import stringWidth from 'string-width';
import { listAgentViewSessionStates } from '../../agent-view/supervisor-store.js';
import {
sanitizeTerminalText,
truncateToWidth,
} from '../../ui/utils/textUtils.js';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
import {
ignoreBrokenPipe,
writeStderrLine,
writeStdoutLine,
} from '../../utils/stdioHelpers.js';
/** Fixed column widths for the human-readable table (exported for tests). */
export const NAME_COL = 22;
@@ -42,6 +41,11 @@ interface PsArgs {
json?: boolean;
}
interface ManagedSession {
sessionId: string;
cwd: string;
}
/**
* Sanitize a record field for terminal output.
*
@@ -79,7 +83,11 @@ export function formatAge(ms: number): string {
return `${Math.floor(hours / 24)}d`;
}
function outputHuman(records: SessionRegistryRecord[], now: number): void {
function outputHuman(
records: SessionRegistryRecord[],
managed: ManagedSession[],
now: number,
): void {
writeStdoutLine(
padDisplay('NAME', NAME_COL) +
padDisplay('KIND', KIND_COL) +
@@ -87,6 +95,18 @@ function outputHuman(records: SessionRegistryRecord[], now: number): void {
padDisplay('AGE', AGE_COL) +
'DIRECTORY',
);
for (const session of managed) {
writeStdoutLine(
padDisplay(
truncateToWidth(sanitize(session.sessionId), NAME_COL - 2),
NAME_COL,
) +
padDisplay('managed', KIND_COL) +
padDisplay('-', PID_COL) +
padDisplay('-', AGE_COL) +
sanitize(session.cwd),
);
}
for (const record of records) {
writeStdoutLine(
padDisplay(
@@ -109,13 +129,33 @@ function outputHuman(records: SessionRegistryRecord[], now: number): void {
}
}
async function readManagedSessions(): Promise<ManagedSession[]> {
try {
return (await listAgentViewSessionStates())
.filter((state) => state.ownership === 'managed')
.map(({ sessionId, activeCwd }) => ({ sessionId, cwd: activeCwd }));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
writeStderrLine(
`Managed sessions could not be listed: ${sanitize(reason)}`,
);
return [];
}
}
async function handlePs(argv: PsArgs): Promise<void> {
ignoreBrokenPipe();
// listLiveSessions reports "cannot look" as "no peers" rather than
// throwing, so there is no failure path to surface here.
const records = await listLiveSessions();
const now = Date.now();
const [records, managed] = await Promise.all([
listLiveSessions(),
readManagedSessions(),
]);
if (argv.json) {
for (const session of managed) {
writeStdoutLine(JSON.stringify({ ...session, managed: true }));
}
for (const record of records) {
// Deliberately raw: field values are emitted exactly as recorded,
// with none of the table path's terminal sanitization. That keeps
@@ -130,17 +170,19 @@ async function handlePs(argv: PsArgs): Promise<void> {
return;
}
if (records.length === 0) {
writeStdoutLine('No Qwen Code sessions are registered right now.');
if (records.length === 0 && managed.length === 0) {
writeStdoutLine(
'No Qwen Code sessions are registered or managed right now.',
);
return;
}
outputHuman(records, now);
outputHuman(records, managed, Date.now());
}
export const psCommand: CommandModule<unknown, PsArgs> = {
command: 'ps',
describe: 'List Qwen Code sessions running right now',
describe: 'List registered and managed Qwen Code sessions',
builder: (yargs: Argv) =>
yargs.option('json', {
type: 'boolean',