diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index d6a7057ec..79b8d8f76 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -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) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5b5081b00..1f9c12e12 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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. */ diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 6fa599b16..6103565bb 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -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 + * `/chats/.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'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1dcef291e..84d5b067a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/utils/runtimeStatus.test.ts b/packages/core/src/utils/runtimeStatus.test.ts new file mode 100644 index 000000000..d663e1d86 --- /dev/null +++ b/packages/core/src/utils/runtimeStatus.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/utils/runtimeStatus.ts b/packages/core/src/utils/runtimeStatus.ts new file mode 100644 index 000000000..59899a5b9 --- /dev/null +++ b/packages/core/src/utils/runtimeStatus.ts @@ -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 { + 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 { + 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; + + // 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 { + 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 { + 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)); + } + } +}