feat(core): write runtime.json sidecar for active sessions (#3714)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Waiting to run
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(core): write runtime.json sidecar for active sessions

Port kimi-cli PR #2082 part 1 to qwen-code.

On interactive session start, atomically write a small JSON sidecar at <projectDir>/chats/<sessionId>.runtime.json recording the (pid, session_id, work_dir, hostname, started_at, qwen_version) tuple.

External tools (terminal multiplexers, IDE integrations, status daemons) can map a running PID to its session id and work dir without parsing argv. Write is best-effort: a read-only filesystem must not block UI startup.

OS process title (was #3713) and dynamic OSC tab title (kimi #2083) remain out of scope.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(core): refresh runtime.json on same-PID session swap

Config.startNewSession() reassigns this.sessionId in the same process,
which is reached by /clear, /reset, /new and /resume. Previously the
old <oldId>.runtime.json was left behind, falsely claiming the still-
live PID for a session no longer being served, and no new sidecar was
written for the incoming session.

Centralize the swap by clearing the old sidecar and writing a fresh
one for the new session id from inside startNewSession itself, so all
same-PID transitions are covered. The refresh runs as a fire-and-
forget best-effort; failures must not block the session switch.

Mirrors the post-merge Codex P1 fix on kimi-cli PR #2082 (the source
of the runtime.json sidecar pattern this PR ports).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(core): only refresh runtime.json when this process owns it

Mirrors kimi-cli PR #2082 commit e237951f (Codex P1 r3158754463): a
short-lived non-interactive invocation (qwen --prompt, ACP, etc.) that
runs `/clear` would otherwise call `Config.startNewSession()`, delete
a concurrent shell's runtime.json sidecar (same outgoing session id),
and never write a replacement — leaving the shell discoverable to
nobody.

Add a `runtimeStatusEnabled` flag on Config, flipped on by the
interactive UI bootstrap immediately after the first successful
sidecar write, and gate the swap-time refresh in
`startNewSession()` on it. Non-interactive entry points never reach
the bootstrap, so they won't touch sibling sidecars.

Kimi later reverted the equivalent `write only from shell mode`
guard (commit 7083975a) in favor of writing from every long-lived
mode, but qwen's wire point is already interactive-only, so the
narrower guard is the right shape here.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Gordon Lam 2026-05-11 01:21:49 +08:00 committed by GitHub
parent 9bd5a0180b
commit 464e4cf343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 613 additions and 0 deletions

View file

@ -15,6 +15,7 @@ import {
SessionService,
type Config,
createDebugLogger,
writeRuntimeStatus,
} from '@qwen-code/qwen-code-core';
import { render } from 'ink';
import dns from 'node:dns';
@ -216,6 +217,28 @@ export async function startInteractiveUI(
) {
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
// Write a small runtime.json sidecar next to the chat log so external
// tools (terminal multiplexers, IDE integrations, status daemons) can
// map the running PID back to its session id and work directory.
// Best-effort: a read-only filesystem must not prevent the UI from
// starting up.
try {
const sessionId = config.getSessionId();
const runtimeStatusPath = config.storage.getRuntimeStatusPath(sessionId);
await writeRuntimeStatus(runtimeStatusPath, {
sessionId,
workDir: config.getTargetDir(),
qwenVersion: version,
});
// Mark this process as the runtime.json owner so subsequent
// session swaps (/clear, /resume, etc.) refresh the sidecar.
// Non-interactive entry points never reach here, so they won't
// trample a sibling shell's sidecar on the same session id.
config.markRuntimeStatusEnabled();
} catch {
// ignored: best-effort, never block UI startup.
}
const restoreTerminalRedrawOptimizer =
process.stdout.isTTY && !config.getScreenReader()
? installTerminalRedrawOptimizer(process.stdout)

View file

@ -116,6 +116,10 @@ import {
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js';
import { Storage } from './storage.js';
import { ChatRecordingService } from '../services/chatRecordingService.js';
import {
clearRuntimeStatus,
writeRuntimeStatus,
} from '../utils/runtimeStatus.js';
import {
SessionService,
type ResumedSessionData,
@ -734,6 +738,7 @@ export class Config {
private readonly overrideExtensions?: string[];
private readonly cliVersion?: string;
private runtimeStatusEnabled = false;
private readonly experimentalZedIntegration: boolean = false;
private readonly cronEnabled: boolean = false;
private readonly emitToolUseSummaries: boolean = true;
@ -1442,6 +1447,7 @@ export class Config {
// Best-effort — don't block session switch
}
const previousSessionId = this.sessionId;
this.sessionId = sessionId ?? randomUUID();
this.sessionData = sessionData;
setDebugLogSession(this);
@ -1468,9 +1474,59 @@ export class Config {
if (this.initialized) {
logStartSession(this, new StartSessionEvent(this));
}
// Refresh the runtime.json sidecar so external observers (terminal
// multiplexers, IDE integrations, status daemons) see the new
// session id rather than a stale claim against a still-live PID.
// /clear, /reset, /new, and /resume all flow through this method,
// so handling the swap centrally covers every same-PID session
// transition. Best-effort: must never block /clear or /resume.
//
// Only refresh when THIS process established its own sidecar at
// startup (interactive UI). A non-interactive `/clear` (e.g.
// qwen --prompt-interactive) must not delete a sibling shell's
// sidecar that happens to share the outgoing session id —
// mirrors kimi-cli PR #2082's "write only when a session is
// established for this process" rule.
if (
this.runtimeStatusEnabled &&
previousSessionId !== this.sessionId
) {
const oldPath = this.storage.getRuntimeStatusPath(previousSessionId);
const newPath = this.storage.getRuntimeStatusPath(this.sessionId);
const cliVersion = this.cliVersion ?? null;
const workDir = this.targetDir;
const newSessionId = this.sessionId;
void (async () => {
try {
await clearRuntimeStatus(oldPath);
await writeRuntimeStatus(newPath, {
sessionId: newSessionId,
workDir,
qwenVersion: cliVersion,
});
} catch {
// ignored: best-effort cleanup
}
})();
}
return this.sessionId;
}
/**
* Marks this Config as the owner of a runtime.json sidecar for the
* current PID. Call once after the initial sidecar write succeeds
* (typically from the interactive UI bootstrap). When set, subsequent
* startNewSession() calls will refresh the sidecar on session swap;
* when unset, startNewSession() leaves sibling sidecars alone so a
* short-lived non-interactive process can't trample a concurrent
* shell's sidecar that happens to share the outgoing session id.
*/
markRuntimeStatusEnabled(): void {
this.runtimeStatusEnabled = true;
}
/**
* Returns the resumed session data if this session was resumed from a previous one.
*/

View file

@ -242,6 +242,18 @@ export class Storage {
return path.join(this.getQwenDir(), 'commands');
}
/**
* Path to the runtime-status sidecar JSON for this session.
*
* Co-located with the per-session chat log under
* `<projectDir>/chats/<sessionId>.runtime.json` so external observers
* (terminal multiplexers, IDE integrations, status daemons) can scan
* the same directory used for chat history to find live sessions.
*/
getRuntimeStatusPath(sessionId: string): string {
return path.join(this.getProjectDir(), 'chats', `${sessionId}.runtime.json`);
}
getProjectTempCheckpointsDir(): string {
return path.join(this.getProjectTempDir(), 'checkpoints');
}

View file

@ -276,6 +276,7 @@ export * from './utils/environmentContext.js';
export * from './utils/errorParsing.js';
export * from './utils/errors.js';
export * from './utils/fileUtils.js';
export * from './utils/runtimeStatus.js';
export * from './utils/filesearch/fileSearch.js';
export * from './utils/formatters.js';
export * from './utils/generateContentResponseUtilities.js';

View file

@ -0,0 +1,271 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdtemp, readFile, rm, writeFile, readdir } from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
RUNTIME_STATUS_SCHEMA_VERSION,
clearRuntimeStatus,
readRuntimeStatus,
writeRuntimeStatus,
} from './runtimeStatus.js';
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'qwen-runtime-status-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
const targetPath = () => path.join(tmpDir, 'runtime.json');
describe('writeRuntimeStatus', () => {
it('writes the expected fields', async () => {
const written = await writeRuntimeStatus(targetPath(), {
sessionId: '11111111-2222-3333-4444-555555555555',
workDir: '/work/dir',
pid: 4242,
qwenVersion: '0.15.3',
});
expect(written).toBe(targetPath());
const data = JSON.parse(await readFile(targetPath(), 'utf-8'));
expect(data.pid).toBe(4242);
expect(data.session_id).toBe('11111111-2222-3333-4444-555555555555');
expect(data.work_dir).toBe('/work/dir');
expect(data.schema_version).toBe(RUNTIME_STATUS_SCHEMA_VERSION);
expect(typeof data.hostname).toBe('string');
expect(data.hostname.length).toBeGreaterThan(0);
expect(typeof data.started_at).toBe('number');
expect(data.qwen_version).toBe('0.15.3');
});
it('defaults pid to process.pid and qwen_version to null', async () => {
await writeRuntimeStatus(targetPath(), {
sessionId: 'abc',
workDir: '/w',
});
const data = JSON.parse(await readFile(targetPath(), 'utf-8'));
expect(data.pid).toBe(process.pid);
expect(data.qwen_version).toBeNull();
});
it('leaves no .tmp leftovers on success', async () => {
await writeRuntimeStatus(targetPath(), {
sessionId: 'abc',
workDir: '/w',
pid: 1,
});
const entries = await readdir(tmpDir);
expect(entries.filter((e) => e.endsWith('.tmp'))).toEqual([]);
});
it('creates the parent directory on demand', async () => {
const nested = path.join(tmpDir, 'a', 'b', 'runtime.json');
await writeRuntimeStatus(nested, { sessionId: 'abc', workDir: '/w' });
const data = JSON.parse(await readFile(nested, 'utf-8'));
expect(data.session_id).toBe('abc');
});
it('atomically overwrites the previous PID on resume', async () => {
await writeRuntimeStatus(targetPath(), {
sessionId: 'abc',
workDir: '/w',
pid: 1000,
});
const first = await readRuntimeStatus(targetPath());
expect(first?.pid).toBe(1000);
await writeRuntimeStatus(targetPath(), {
sessionId: 'abc',
workDir: '/w',
pid: 2000,
});
const second = await readRuntimeStatus(targetPath());
expect(second?.pid).toBe(2000);
});
it('preserves non-ASCII characters in path components and session ids', async () => {
await writeRuntimeStatus(targetPath(), {
sessionId: '中文-uuid-aaa',
workDir: 'D:/项目/我的-app',
pid: 7777,
});
const status = await readRuntimeStatus(targetPath());
expect(status?.sessionId).toBe('中文-uuid-aaa');
expect(status?.workDir).toBe('D:/项目/我的-app');
const rawBytes = await readFile(targetPath());
expect(rawBytes.includes(Buffer.from('中文', 'utf-8'))).toBe(true);
});
});
describe('readRuntimeStatus', () => {
it('round-trips a written record', async () => {
await writeRuntimeStatus(targetPath(), {
sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
workDir: '/some/where',
pid: 99,
qwenVersion: '0.15.3',
});
const status = await readRuntimeStatus(targetPath());
expect(status).not.toBeNull();
expect(status!.pid).toBe(99);
expect(status!.sessionId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
expect(status!.workDir).toBe('/some/where');
expect(status!.schemaVersion).toBe(RUNTIME_STATUS_SCHEMA_VERSION);
expect(status!.qwenVersion).toBe('0.15.3');
});
it('returns null when the file is missing', async () => {
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('returns null on malformed JSON', async () => {
await writeFile(targetPath(), 'not-json', 'utf-8');
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('returns null on an unknown schema version', async () => {
await writeFile(
targetPath(),
JSON.stringify({
schema_version: RUNTIME_STATUS_SCHEMA_VERSION + 99,
pid: 1,
session_id: 'x',
work_dir: '/w',
hostname: 'h',
started_at: 0,
qwen_version: null,
}),
'utf-8',
);
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('returns null when session_id has the wrong type', async () => {
await writeFile(
targetPath(),
JSON.stringify({
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
pid: 1,
session_id: null,
work_dir: '/w',
hostname: 'h',
started_at: 0,
qwen_version: null,
}),
'utf-8',
);
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('returns null when pid is a string', async () => {
await writeFile(
targetPath(),
JSON.stringify({
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
pid: '1234',
session_id: 'abc',
work_dir: '/w',
hostname: 'h',
started_at: 0,
qwen_version: null,
}),
'utf-8',
);
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('returns null when work_dir is an array', async () => {
await writeFile(
targetPath(),
JSON.stringify({
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
pid: 1,
session_id: 'abc',
work_dir: ['/', 'w'],
hostname: 'h',
started_at: 0,
qwen_version: null,
}),
'utf-8',
);
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('returns null on an array root payload', async () => {
await writeFile(targetPath(), JSON.stringify([1, 2, 3]), 'utf-8');
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('returns null on invalid UTF-8 bytes', async () => {
// Truncated multi-byte sequence
await writeFile(targetPath(), Buffer.from([0xff, 0xfe, 0x20, 0x67]));
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
});
describe('clearRuntimeStatus', () => {
it('removes an existing file', async () => {
await writeRuntimeStatus(targetPath(), {
sessionId: 'abc',
workDir: '/w',
pid: 1,
});
await clearRuntimeStatus(targetPath());
expect(await readRuntimeStatus(targetPath())).toBeNull();
});
it('is idempotent on a missing file', async () => {
await clearRuntimeStatus(targetPath());
await writeRuntimeStatus(targetPath(), {
sessionId: 'abc',
workDir: '/w',
pid: 1,
});
await clearRuntimeStatus(targetPath());
await clearRuntimeStatus(targetPath());
});
it('does not throw on a non-existent directory', async () => {
await clearRuntimeStatus(path.join(tmpDir, 'does-not-exist', 'r.json'));
});
});
describe('same-PID session swap', () => {
// Models the /clear, /reset, /new and /resume flow: same PID transitions
// from session A to session B. The old sidecar must be removed before the
// new one is written so external observers can't double-claim the PID.
it('clears the old sidecar before writing the new one', async () => {
const oldPath = path.join(tmpDir, 'session-a.runtime.json');
const newPath = path.join(tmpDir, 'session-b.runtime.json');
await writeRuntimeStatus(oldPath, {
sessionId: 'session-a',
workDir: '/w',
pid: 4242,
qwenVersion: '0.0.0-test',
});
expect(await readRuntimeStatus(oldPath)).not.toBeNull();
await clearRuntimeStatus(oldPath);
await writeRuntimeStatus(newPath, {
sessionId: 'session-b',
workDir: '/w',
pid: 4242,
qwenVersion: '0.0.0-test',
});
expect(await readRuntimeStatus(oldPath)).toBeNull();
const after = await readRuntimeStatus(newPath);
expect(after?.sessionId).toBe('session-b');
expect(after?.pid).toBe(4242);
});
});

View file

@ -0,0 +1,250 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Runtime status sidecar for an active interactive Qwen Code session.
*
* This module writes a small JSON file alongside the session's chat log
* while an interactive session is alive. It exists so that **external**
* tools (terminal multiplexers, tab managers, IDE integrations,
* observability daemons) can answer the question:
*
* "Which Qwen Code session is the running PID X serving?"
*
* The CLI does not embed the session id in `argv` for fresh
* (non-resumed) sessions, and the OS process title can be truncated, so
* a side-channel file that records the explicit
* `(pid, session_id, work_dir, ...)` tuple is the most reliable
* cross-platform signal.
*
* Lifecycle:
* - Written on session start (clean launch or resume); the resume case
* atomically overwrites whatever the previous PID wrote.
* - **Not** deleted on clean `/quit` or on crash. From an external
* observer's standpoint the recorded PID no longer exists in either
* case, so a liveness check is sufficient and an explicit cleanup
* adds nothing.
* - `clearRuntimeStatus` exists for the narrow case where the same PID
* keeps running while no longer serving the recorded session
* (e.g. a hypothetical future mode-switch). Not currently invoked.
*
* The file is written atomically (tmp-file + rename) and contains a
* small, stable schema. External consumers should treat unknown fields
* as forward-compatible additions.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { isNodeError } from './errors.js';
export const RUNTIME_STATUS_SCHEMA_VERSION = 1;
/** Snapshot of a live Qwen Code session process for external observers. */
export interface RuntimeStatus {
schemaVersion: number;
pid: number;
sessionId: string;
workDir: string;
hostname: string;
/** Epoch seconds (with sub-second precision). Matches kimi-cli's format. */
startedAt: number;
qwenVersion: string | null;
}
/**
* On-disk JSON shape. Keys are snake_case to match the cross-tool
* convention established by kimi-cli's `runtime.json`, so external
* observers can use one parser for both ecosystems.
*/
interface RuntimeStatusOnDisk {
schema_version: number;
pid: number;
session_id: string;
work_dir: string;
hostname: string;
started_at: number;
qwen_version: string | null;
}
export interface WriteRuntimeStatusFields {
sessionId: string;
workDir: string;
/** Defaults to `process.pid`. */
pid?: number;
/** Defaults to `null`. Pass the value of `getCliVersion()`. */
qwenVersion?: string | null;
}
/**
* Atomically write the runtime status file at `filePath`.
*
* Writes via tmp-file + rename so an external observer never sees a
* partially written file: it sees either the previous contents or the
* fully committed new contents.
*
* The parent directory of `filePath` is created on demand. Exceptions
* from the underlying I/O propagate to the caller; this function does
* not log or swallow them. Callers that want best-effort semantics
* should wrap the call in a try/catch. On failure no leftover `.tmp`
* file is kept on disk.
*/
export async function writeRuntimeStatus(
filePath: string,
fields: WriteRuntimeStatusFields,
): Promise<string> {
const payload: RuntimeStatusOnDisk = {
schema_version: RUNTIME_STATUS_SCHEMA_VERSION,
pid: fields.pid ?? process.pid,
session_id: fields.sessionId,
work_dir: fields.workDir,
hostname: os.hostname(),
started_at: Date.now() / 1000,
qwen_version: fields.qwenVersion ?? null,
};
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
try {
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
await renameWithRetry(tmpPath, filePath, 3, 50);
} catch (err) {
try {
await fs.unlink(tmpPath);
} catch {
// ignore cleanup errors
}
throw err;
}
return filePath;
}
/**
* Read the runtime status file at `filePath`, if present.
*
* Returns `null` if the file is missing, malformed (truncated UTF-8,
* invalid JSON, non-object payload, wrong field types), or written by a
* schema version this code does not understand. The function never
* coerces null/array/object into a string just to satisfy the
* dataclass.
*
* Note: a returned record only proves that *some* Qwen Code process
* once claimed this session. The PID may already be dead (clean quit
* or crash). Consumers must verify liveness themselves before treating
* the record as a currently-running session.
*/
export async function readRuntimeStatus(
filePath: string,
): Promise<RuntimeStatus | null> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch (err) {
if (isNodeError(err) && err.code === 'ENOENT') {
return null;
}
if (err instanceof Error && err.message.includes('utf-8')) {
return null;
}
return null;
}
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
return null;
}
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
return null;
}
const obj = data as Record<string, unknown>;
// Schema gate first: an unknown schema_version is not our concern.
if (obj['schema_version'] !== RUNTIME_STATUS_SCHEMA_VERSION) {
return null;
}
const schemaVersion = obj['schema_version'];
const pid = obj['pid'];
const sessionId = obj['session_id'];
const workDir = obj['work_dir'];
const hostname = obj['hostname'];
const startedAt = obj['started_at'];
const qwenVersion = obj['qwen_version'];
if (!isFiniteIntegerNotBool(schemaVersion)) return null;
if (!isFiniteIntegerNotBool(pid)) return null;
if (typeof sessionId !== 'string') return null;
if (typeof workDir !== 'string') return null;
if (typeof hostname !== 'string') return null;
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
return null;
}
if (qwenVersion !== null && typeof qwenVersion !== 'string') return null;
return {
schemaVersion,
pid,
sessionId,
workDir,
hostname,
startedAt,
qwenVersion,
};
}
/**
* Remove the runtime status file at `filePath`, if present.
*
* Intentionally **not** called on `/quit` when the qwen-code process
* exits, an external observer's PID-liveness check already detects the
* missing process, so a stale record is harmless. This helper exists
* for the narrow case where the **same PID continues running** but
* stops serving the recorded session.
*
* Safe to call multiple times and on paths that no longer exist;
* `ENOENT` and other `OSError`-class failures are swallowed so cleanup
* cannot disrupt the surrounding control flow.
*/
export async function clearRuntimeStatus(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch {
// ignored: best-effort cleanup
}
}
function isFiniteIntegerNotBool(v: unknown): v is number {
return (
typeof v === 'number' &&
Number.isInteger(v) &&
Number.isFinite(v) &&
typeof v !== 'boolean'
);
}
async function renameWithRetry(
src: string,
dest: string,
retries: number,
delayMs: number,
): Promise<void> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
await fs.rename(src, dest);
return;
} catch (err) {
const retryable =
isNodeError(err) && (err.code === 'EPERM' || err.code === 'EACCES');
if (!retryable || attempt === retries) {
throw err;
}
await new Promise((r) => setTimeout(r, delayMs * 2 ** attempt));
}
}
}