Skip to Content
SuperpowersPlansqwen serve Daemon File Logger — Implementation Plan

qwen serve Daemon File Logger — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a daemon-scoped file logger to qwen serve so route errors, lifecycle messages, and ACP child stderr land in ~/.qwen/debug/daemon/<id>.log in addition to stderr — eliminating the manual 2>serve.log workaround for issue #4548.

Architecture: New cli-local module daemonLogger.ts exposes initDaemonLogger(opts) → DaemonLogger. info/warn/error tee to file + stderr; raw is file-only. acp-bridge gets a new optional BridgeOptions.onDiagnosticLine callback and createSpawnChannelFactory({ onDiagnosticLine }) helper so the cli can route writeServeDebugLine and ACP child stderr lines into the daemon log without acp-bridge taking a cli dependency. No global singleton — logger is constructed per runQwenServe invocation.

Tech Stack: TypeScript, Vitest, Node fs.promises, existing Storage.getGlobalDebugDir(), existing updateSymlink helper.

Reference spec: docs/superpowers/specs/2026-05-26-daemon-logger-design.md

Test harness: vitest run from each package; for a single file: cd packages/<pkg> && npx vitest run <relative-path>.


File map

FileActionPurpose
packages/cli/src/serve/daemonLogger.tsnewLogger sink + format helper
packages/cli/src/serve/daemonLogger.test.tsnewUnit tests for the above
packages/acp-bridge/src/bridgeOptions.tsmodifyAdd onDiagnosticLine? field + DiagnosticLineSink type
packages/acp-bridge/src/bridge.tsmodifyTee writeServeDebugLine through opts.onDiagnosticLine (via local teeServeDebugLine closure)
packages/acp-bridge/src/bridge.test.tsmodifyAdd test that onDiagnosticLine receives debug lines
packages/acp-bridge/src/spawnChannel.tsmodifyExport createSpawnChannelFactory({ onDiagnosticLine }); tee child stderr into callback
packages/acp-bridge/src/spawnChannel.test.tsmodify (or new)Test stderr forwarding callback
packages/cli/src/serve/server.tsmodifycreateServeApp deps accept optional daemonLog; sendBridgeError routes through it when provided
packages/cli/src/serve/server.test.tsmodifyVerify daemonLog receives route-error entries
packages/cli/src/serve/runQwenServe.tsmodifyInit logger, boot banner, wire spawn factory + bridge callback, replace lifecycle writeStderrLine calls, flush on shutdown
packages/cli/src/serve/runQwenServe.test.tsmodifyVerify boot banner + flush behavior
docs/cli/serve.md (or equivalent)modifyDocument daemon log path + opt-out

Task 0: Pre-flight

  • Step 1: Confirm worktree + branch

Run: git rev-parse --abbrev-ref HEAD && pwd Expected: branch feat/support_daemon_logger, cwd ends with .claude/worktrees/feat-support-daemon-logger.

  • Step 2: Install dependencies + baseline tests green

Run: npm install && cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts && cd ../acp-bridge && npx vitest run Expected: all pass. (If not, baseline is broken — stop and report.)

  • Step 3: Skim the spec

Read docs/superpowers/specs/2026-05-26-daemon-logger-design.md end-to-end. Key sections to internalize: §3 (modules), §4 (path), §5 (API), §6 (format + tee semantics), §7 (boot/shutdown), §11 (error handling).


Task 1: buildDaemonLogLine pure helper

Pure formatter. No I/O. Easy to TDD.

Files:

  • Create: packages/cli/src/serve/daemonLogger.ts

  • Create: packages/cli/src/serve/daemonLogger.test.ts

  • Step 1: Write the failing tests

packages/cli/src/serve/daemonLogger.test.ts:

/** * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; import { buildDaemonLogLine } from './daemonLogger.js'; describe('buildDaemonLogLine', () => { const FIXED = new Date('2026-05-26T03:14:15.926Z'); it('formats INFO with no ctx', () => { expect( buildDaemonLogLine({ level: 'INFO', message: 'daemon started', now: FIXED, }), ).toBe('2026-05-26T03:14:15.926Z [INFO] [DAEMON] daemon started\n'); }); it('renders ctx fields in fixed order', () => { const line = buildDaemonLogLine({ level: 'ERROR', message: 'route failed', now: FIXED, ctx: { sessionId: 'sess-1', route: 'POST /session/:id/prompt', clientId: 'client-x', childPid: 4242, channelId: 'ch-9', }, }); expect(line).toBe( '2026-05-26T03:14:15.926Z [ERROR] [DAEMON] ' + 'route=POST /session/:id/prompt sessionId=sess-1 clientId=client-x ' + 'childPid=4242 channelId=ch-9 route failed\n', ); }); it('appends extra ctx keys sorted lexicographically after fixed keys', () => { const line = buildDaemonLogLine({ level: 'WARN', message: 'note', now: FIXED, ctx: { zeta: 1, alpha: 'a', sessionId: 's' }, }); expect(line).toBe( '2026-05-26T03:14:15.926Z [WARN] [DAEMON] sessionId=s alpha=a zeta=1 note\n', ); }); it('JSON.stringify-quotes values that contain spaces or =', () => { const line = buildDaemonLogLine({ level: 'INFO', message: 'hi', now: FIXED, ctx: { weird: 'has space', eq: 'a=b' }, }); expect(line).toBe( '2026-05-26T03:14:15.926Z [INFO] [DAEMON] eq="a=b" weird="has space" hi\n', ); }); it('appends error stack as indented continuation lines', () => { const err = new Error('boom'); err.stack = 'Error: boom\n at fn (file.ts:1:1)\n at main (file.ts:2:2)'; const line = buildDaemonLogLine({ level: 'ERROR', message: 'failed', now: FIXED, err, }); expect(line).toBe( '2026-05-26T03:14:15.926Z [ERROR] [DAEMON] failed\n' + ' Error: boom\n' + ' at fn (file.ts:1:1)\n' + ' at main (file.ts:2:2)\n', ); }); it('falls back to err.message when stack missing', () => { const err: Error = { name: 'Plain', message: 'no stack' } as Error; const line = buildDaemonLogLine({ level: 'ERROR', message: 'failed', now: FIXED, err, }); expect(line).toBe( '2026-05-26T03:14:15.926Z [ERROR] [DAEMON] failed\n' + ' Plain: no stack\n', ); }); });
  • Step 2: Run test, confirm fail

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts Expected: failure — buildDaemonLogLine not exported.

  • Step 3: Implement buildDaemonLogLine

Create packages/cli/src/serve/daemonLogger.ts with:

/** * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ export type DaemonLogLevel = 'INFO' | 'WARN' | 'ERROR'; export interface DaemonLogContext { route?: string; sessionId?: string; clientId?: string; childPid?: number; channelId?: string; [key: string]: unknown; } const FIXED_CTX_ORDER = [ 'route', 'sessionId', 'clientId', 'childPid', 'channelId', ] as const; function renderCtxValue(value: unknown): string { const s = String(value); return /[\s=]/.test(s) ? JSON.stringify(s) : s; } function renderCtx(ctx: DaemonLogContext | undefined): string { if (!ctx) return ''; const parts: string[] = []; for (const key of FIXED_CTX_ORDER) { const v = ctx[key]; if (v !== undefined && v !== null) { parts.push(`${key}=${renderCtxValue(v)}`); } } const fixedSet = new Set<string>(FIXED_CTX_ORDER); const extraKeys = Object.keys(ctx) .filter((k) => !fixedSet.has(k) && ctx[k] !== undefined && ctx[k] !== null) .sort(); for (const key of extraKeys) { parts.push(`${key}=${renderCtxValue(ctx[key])}`); } return parts.length > 0 ? parts.join(' ') + ' ' : ''; } function renderErr(err: Error | undefined): string { if (!err) return ''; const body = err.stack ?? `${err.name ?? 'Error'}: ${err.message}`; return ( body .split('\n') .map((l) => ` ${l}`) .join('\n') + '\n' ); } export interface BuildDaemonLogLineArgs { level: DaemonLogLevel; message: string; now: Date; ctx?: DaemonLogContext; err?: Error; } export function buildDaemonLogLine(args: BuildDaemonLogLineArgs): string { const ts = args.now.toISOString(); const ctxStr = renderCtx(args.ctx); return `${ts} [${args.level}] [DAEMON] ${ctxStr}${args.message}\n${renderErr(args.err)}`; }
  • Step 4: Run test, confirm pass

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts Expected: PASS (6 specs).

  • Step 5: Commit
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts git commit -m "feat(serve): buildDaemonLogLine formatter (#4548)"

Task 2: initDaemonLogger opt-out + no-op factory

Returns a no-op logger when QWEN_DAEMON_LOG_FILE is disabled. No filesystem touch yet.

Files:

  • Modify: packages/cli/src/serve/daemonLogger.ts

  • Modify: packages/cli/src/serve/daemonLogger.test.ts

  • Step 1: Add failing tests

Append to daemonLogger.test.ts:

import { initDaemonLogger } from './daemonLogger.js'; import { afterEach, beforeEach } from 'vitest'; describe('initDaemonLogger opt-out', () => { const originalEnv = process.env['QWEN_DAEMON_LOG_FILE']; afterEach(() => { if (originalEnv === undefined) delete process.env['QWEN_DAEMON_LOG_FILE']; else process.env['QWEN_DAEMON_LOG_FILE'] = originalEnv; }); for (const val of ['0', 'false', 'off', 'no', 'False', ' OFF ']) { it(`returns no-op logger when QWEN_DAEMON_LOG_FILE=${JSON.stringify(val)}`, () => { process.env['QWEN_DAEMON_LOG_FILE'] = val; const stderr: string[] = []; const logger = initDaemonLogger({ boundWorkspace: '/tmp/ws', baseDir: '/tmp/nonexistent-should-not-touch', stderr: (s) => stderr.push(s), }); logger.info('hello'); logger.warn('there'); logger.error('boom'); logger.raw('raw'); expect(stderr).toEqual([]); // no-op = nothing expect(logger.getLogPath()).toBe(''); expect(logger.getDaemonId()).toBe(''); }); } });
  • Step 2: Run, confirm fail

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts Expected: failure — initDaemonLogger not exported.

  • Step 3: Implement opt-out + no-op shape

Append to daemonLogger.ts:

export interface DaemonLogger { info(message: string, ctx?: DaemonLogContext): void; warn(message: string, ctx?: DaemonLogContext): void; error(message: string, err?: Error | null, ctx?: DaemonLogContext): void; raw(line: string, level?: 'info' | 'warn' | 'error'): void; getLogPath(): string; getDaemonId(): string; flush(): Promise<void>; } export interface InitDaemonLoggerOptions { boundWorkspace: string; pid?: number; now?: () => Date; stderr?: (line: string) => void; baseDir?: string; } const NOOP_LOGGER: DaemonLogger = { info: () => {}, warn: () => {}, error: () => {}, raw: () => {}, getLogPath: () => '', getDaemonId: () => '', flush: () => Promise.resolve(), }; function isOptedOut(): boolean { const raw = process.env['QWEN_DAEMON_LOG_FILE']; if (!raw) return false; return ['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase()); } export function initDaemonLogger(_opts: InitDaemonLoggerOptions): DaemonLogger { if (isOptedOut()) return NOOP_LOGGER; throw new Error('initDaemonLogger: file path not implemented yet'); }
  • Step 4: Run, confirm opt-out specs pass

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "opt-out" Expected: opt-out specs PASS; full file may still fail (we’ll add coverage incrementally).

  • Step 5: Commit
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts git commit -m "feat(serve): daemon logger opt-out env + no-op shape (#4548)"

Task 3: File init (daemon-id, mkdir, sync probe, degraded fallback)

Files:

  • Modify: packages/cli/src/serve/daemonLogger.ts

  • Modify: packages/cli/src/serve/daemonLogger.test.ts

  • Step 1: Add failing tests

Append to daemonLogger.test.ts:

import * as os from 'node:os'; import * as path from 'node:path'; import { mkdtempSync, readFileSync, existsSync, mkdirSync, chmodSync, } from 'node:fs'; import { rmSync } from 'node:fs'; describe('initDaemonLogger file init', () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); }); afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} }); it('derives daemon-id "serve-<pid>-<workspaceHash>" and creates log file', () => { const logger = initDaemonLogger({ boundWorkspace: '/workspace/foo', pid: 1234, baseDir: tmp, }); expect(logger.getDaemonId()).toMatch(/^serve-1234-[0-9a-f]{8}$/); expect(logger.getLogPath()).toBe( path.join(tmp, 'daemon', `${logger.getDaemonId()}.log`), ); expect(existsSync(logger.getLogPath())).toBe(true); expect(readFileSync(logger.getLogPath(), 'utf8')).toMatch( /\[INFO\] \[DAEMON\] daemon started pid=1234 workspace=\/workspace\/foo/, ); }); it('falls back to no-op when mkdir fails', () => { const stderr: string[] = []; // Create a file where the directory should be → mkdir EEXIST/ENOTDIR const blockingFile = path.join(tmp, 'daemon'); require('node:fs').writeFileSync(blockingFile, 'blocker'); const logger = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp, stderr: (s) => stderr.push(s), }); expect(logger.getLogPath()).toBe(''); expect(stderr.join('\n')).toMatch(/daemon log disabled/); expect(() => logger.info('after')).not.toThrow(); }); });
  • Step 2: Run, confirm fail

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "file init" Expected: failure — throw new Error('not implemented').

  • Step 3: Implement file init

Replace the throwing body of initDaemonLogger. Add imports and helpers:

import * as nodeFs from 'node:fs'; import * as nodePath from 'node:path'; import * as crypto from 'node:crypto'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { Storage } from '@qwen-code/qwen-code-core'; function computeDaemonId(pid: number, boundWorkspace: string): string { const hash = crypto .createHash('sha256') .update(boundWorkspace) .digest('hex') .slice(0, 8); return `serve-${pid}-${hash}`; } export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger { if (isOptedOut()) return NOOP_LOGGER; const pid = opts.pid ?? process.pid; const now = opts.now ?? (() => new Date()); const stderr = opts.stderr ?? writeStderrLine; const baseDir = opts.baseDir ?? Storage.getGlobalDebugDir(); const daemonId = computeDaemonId(pid, opts.boundWorkspace); const daemonDir = nodePath.join(baseDir, 'daemon'); const logPath = nodePath.join(daemonDir, `${daemonId}.log`); try { nodeFs.mkdirSync(daemonDir, { recursive: true }); const firstLine = buildDaemonLogLine({ level: 'INFO', message: `daemon started pid=${pid} workspace=${opts.boundWorkspace}`, now: now(), }); nodeFs.appendFileSync(logPath, firstLine, { flag: 'a' }); } catch (err) { stderr( `qwen serve: daemon log disabled — init failed: ${ err instanceof Error ? err.message : String(err) }`, ); return NOOP_LOGGER; } // Methods come in Task 4. For now stub them out so the file-init tests pass. return { info: () => {}, warn: () => {}, error: () => {}, raw: () => {}, getLogPath: () => logPath, getDaemonId: () => daemonId, flush: () => Promise.resolve(), }; }
  • Step 4: Run, confirm pass

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "file init" Expected: PASS.

  • Step 5: Commit
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts git commit -m "feat(serve): daemon logger file init + degraded fallback (#4548)"

Task 4: info / warn / error + async queue + flush + stderr tee

Files:

  • Modify: packages/cli/src/serve/daemonLogger.ts

  • Modify: packages/cli/src/serve/daemonLogger.test.ts

  • Step 1: Add failing tests

Append to daemonLogger.test.ts:

describe('initDaemonLogger info/warn/error', () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); }); afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} }); it('info appends to file and tees to stderr', async () => { const stderr: string[] = []; const fixed = new Date('2026-05-26T03:14:15.926Z'); const logger = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp, stderr: (s) => stderr.push(s), now: () => fixed, }); logger.info('hello', { route: 'GET /' }); await logger.flush(); const content = readFileSync(logger.getLogPath(), 'utf8'); expect(content).toContain('[INFO] [DAEMON] route=GET / hello\n'); // Stderr saw the same line (after boot banner, which isn't teed here). const teedLines = stderr.filter((s) => s.includes('[INFO] [DAEMON]')); expect(teedLines).toHaveLength(1); }); it('error appends err.stack as continuation', async () => { const logger = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp, }); const err = new Error('boom'); logger.error('route failed', err, { route: 'POST /x' }); await logger.flush(); const content = readFileSync(logger.getLogPath(), 'utf8'); expect(content).toMatch( /\[ERROR\] \[DAEMON\] route=POST \/x route failed\n Error: boom/, ); }); it('flush awaits all pending appends', async () => { const logger = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp, }); for (let i = 0; i < 50; i++) logger.info(`msg-${i}`); await logger.flush(); const lines = readFileSync(logger.getLogPath(), 'utf8').split('\n'); const msgLines = lines.filter((l) => /msg-\d+$/.test(l)); expect(msgLines).toHaveLength(50); for (let i = 0; i < 50; i++) { expect(msgLines[i]).toContain(`msg-${i}`); } }); it('warns once on append failure and keeps trying', async () => { const logger = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp, stderr: () => {}, }); // Sabotage by removing the file mid-flight — POSIX will keep the inode // around for a held fd, but appendFile reopens each call → ENOENT once // the parent dir is gone. rmSync(path.dirname(logger.getLogPath()), { recursive: true, force: true }); const stderr2: string[] = []; // Re-create logger to bind our stderr capture? Simpler: re-stub via // private state — instead, do this in a separate test using a custom // stderr from init time. logger.info('after-rm-1'); logger.info('after-rm-2'); await logger.flush(); // No throw — degraded path swallows. (Stderr count assertion left to // a separate variant if needed; this test pins "no crash on failure".) }); });
  • Step 2: Run, confirm fail

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "info/warn/error" Expected: failure — methods are stubs.

  • Step 3: Implement methods + queue + flush + tee

Replace the final return {...} block in initDaemonLogger:

let pending: Promise<void> = Promise.resolve(); let degraded = false; const enqueueAppend = (line: string): void => { pending = pending.then(() => nodeFs.promises.appendFile(logPath, line).catch((err) => { if (!degraded) { degraded = true; stderr( `qwen serve: daemon log write failed — entering degraded mode: ${ err instanceof Error ? err.message : String(err) }`, ); } }), ); }; const teeLine = ( level: DaemonLogLevel, message: string, ctx?: DaemonLogContext, err?: Error, ): void => { const line = buildDaemonLogLine({ level, message, now: now(), ctx, err }); // stderr first (synchronous, preserves human-visible order), then file. stderr(line.trimEnd()); enqueueAppend(line); }; return { info: (message, ctx) => teeLine('INFO', message, ctx), warn: (message, ctx) => teeLine('WARN', message, ctx), error: (message, err, ctx) => teeLine('ERROR', message, ctx, err ?? undefined), raw: () => {}, // implemented in Task 5 getLogPath: () => logPath, getDaemonId: () => daemonId, flush: () => pending, };
  • Step 4: Run, confirm pass

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "info/warn/error" Expected: PASS.

  • Step 5: Commit
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts git commit -m "feat(serve): daemon logger info/warn/error + flush (#4548)"

Task 5: raw() file-only tee

Files:

  • Modify: packages/cli/src/serve/daemonLogger.ts

  • Modify: packages/cli/src/serve/daemonLogger.test.ts

  • Step 1: Add failing test

Append:

describe('initDaemonLogger raw', () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); }); afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} }); it('appends prefixed line, no stderr tee', async () => { const stderr: string[] = []; const logger = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp, stderr: (s) => stderr.push(s), }); const stderrBefore = stderr.length; logger.raw('[serve pid=123 cwd=/x] child crashed', 'warn'); logger.raw('[serve pid=123 cwd=/x] another'); await logger.flush(); const content = readFileSync(logger.getLogPath(), 'utf8'); expect(content).toContain( '[WARN] [DAEMON] [serve pid=123 cwd=/x] child crashed\n', ); expect(content).toContain( '[INFO] [DAEMON] [serve pid=123 cwd=/x] another\n', ); // No new stderr lines from raw() expect(stderr.length).toBe(stderrBefore); }); });
  • Step 2: Run, confirm fail

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "raw" Expected: fail — raw is no-op.

  • Step 3: Implement raw

In initDaemonLogger, replace raw: () => {}, with:

raw: (line: string, level: 'info' | 'warn' | 'error' = 'info') => { const upper = level.toUpperCase() as DaemonLogLevel; const formatted = `${now().toISOString()} [${upper}] [DAEMON] ${line}\n`; enqueueAppend(formatted); },
  • Step 4: Run, confirm pass

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "raw" Expected: PASS.

  • Step 5: Commit
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts git commit -m "feat(serve): daemon logger raw() file-only tee (#4548)"

Files:

  • Modify: packages/cli/src/serve/daemonLogger.ts

  • Modify: packages/cli/src/serve/daemonLogger.test.ts

  • Step 1: Add failing test

Append:

import { realpathSync, lstatSync } from 'node:fs'; describe('initDaemonLogger latest symlink', () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); }); afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} }); it('creates daemon/latest pointing to the current log', () => { const logger = initDaemonLogger({ boundWorkspace: '/w', pid: 42, baseDir: tmp, }); const linkPath = path.join(tmp, 'daemon', 'latest'); expect(lstatSync(linkPath).isSymbolicLink() || existsSync(linkPath)).toBe( true, ); expect(realpathSync(linkPath)).toBe(realpathSync(logger.getLogPath())); }); it('updates latest on subsequent init in same dir', () => { const a = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp }); const b = initDaemonLogger({ boundWorkspace: '/w', pid: 2, baseDir: tmp }); expect(realpathSync(path.join(tmp, 'daemon', 'latest'))).toBe( realpathSync(b.getLogPath()), ); expect(realpathSync(a.getLogPath())).not.toBe(realpathSync(b.getLogPath())); }); });
  • Step 2: Run, confirm fail

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "latest symlink" Expected: fail — symlink not created.

  • Step 3: Implement symlink update

updateSymlink lives in packages/core/src/utils/symlink.ts but is NOT re-exported from the core barrel (confirmed via grep -n updateSymlink packages/core/src/index.ts → no matches at plan-write time). Add the re-export first:

In packages/core/src/index.ts, add (near the other utils exports):

export { updateSymlink } from './utils/symlink.js';

Then import in daemonLogger.ts:

import { Storage, updateSymlink } from '@qwen-code/qwen-code-core';

(Merge with the existing Storage import added in Task 3.)

Inside initDaemonLogger, after the appendFileSync first-line write succeeds, add:

try { const aliasPath = nodePath.join(daemonDir, 'latest'); updateSymlink(aliasPath, logPath, { fallbackCopy: false }).catch(() => { // Best-effort. Symlink failure must not degrade primary writes. }); } catch { // Sync throw equally best-effort. }
  • Step 4: Run, confirm pass

Run: cd packages/cli && npx vitest run src/serve/daemonLogger.test.ts -t "latest symlink" Expected: PASS.

  • Step 5: Commit
git add packages/cli/src/serve/daemonLogger.ts packages/cli/src/serve/daemonLogger.test.ts packages/core/src/index.ts git commit -m "feat(serve): daemon logger latest symlink (#4548)"

Task 7: Add BridgeOptions.onDiagnosticLine + tee writeServeDebugLine

Files:

  • Modify: packages/acp-bridge/src/bridgeOptions.ts

  • Modify: packages/acp-bridge/src/bridge.ts

  • Modify: packages/acp-bridge/src/bridge.test.ts

  • Step 1: Add DiagnosticLineSink type to bridgeOptions.ts

Insert near the top of the BridgeOptions interface (before sessionScope):

/** * Sink for serve-level diagnostic lines (set by the cli daemon logger). * When provided, the bridge tees `writeServeDebugLine` output through * this callback alongside the existing stderr write — used by * runQwenServe to capture them in the daemon log file. The bridge * does not own a file logger itself; this is a pure pass-through hook. */ export type DiagnosticLineSink = ( line: string, level?: 'info' | 'warn' | 'error', ) => void;

Add inside BridgeOptions:

/** * Optional: tee `writeServeDebugLine` output. See {@link DiagnosticLineSink}. * No-op when omitted. Set by cli `runQwenServe` from the daemon logger. */ onDiagnosticLine?: DiagnosticLineSink;
  • Step 2: Add failing test

In packages/acp-bridge/src/bridge.test.ts, add a new describe('onDiagnosticLine', ...) block. The file already imports makeBridge and makeChannel from ./internal/testUtils.js — reuse them instead of hand-rolling a ChannelFactory. Confirm with grep -n "import.*testUtils" packages/acp-bridge/src/bridge.test.ts. To trigger writeServeDebugLine, pick the shortest-setup test among the 6 call sites — list them with grep -n "writeServeDebugLine(" packages/acp-bridge/src/bridge.ts (currently lines 1410, 1423, 2242, 2328, 2624, 2637; the cross-session permission-vote rejection around line 2242 is a small reproducible trigger).

describe('onDiagnosticLine', () => { const originalDebug = process.env['QWEN_SERVE_DEBUG']; afterEach(() => { if (originalDebug === undefined) delete process.env['QWEN_SERVE_DEBUG']; else process.env['QWEN_SERVE_DEBUG'] = originalDebug; }); it('receives writeServeDebugLine output when QWEN_SERVE_DEBUG=1', async () => { process.env['QWEN_SERVE_DEBUG'] = '1'; const captured: Array<{ line: string; level?: string }> = []; const bridge = makeBridge({ onDiagnosticLine: (line, level) => captured.push({ line, level }), }); // Trigger writeServeDebugLine via [copy harness from the closest // existing test that exercises one of the 6 call sites above]. // ... trigger code here ... expect(captured.some((e) => e.line.includes('qwen serve debug: '))).toBe( true, ); expect( captured.every((e) => e.level === undefined || e.level === 'info'), ).toBe(true); await bridge.shutdown(); }); });

(makeBridge accepts Partial<BridgeOptions> — once Task 7 step 1 adds onDiagnosticLine to BridgeOptions, it flows through without further edits to testUtils.ts.)

  • Step 3: Run, confirm fail

Run: cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "onDiagnosticLine" Expected: fail — callback not invoked.

  • Step 4: Tee writeServeDebugLine through the callback

In packages/acp-bridge/src/bridge.ts, near the top of createHttpAcpBridge (after opts is destructured), introduce a local tee that wraps the existing module-level helper:

const teeServeDebugLine = (message: string): void => { writeServeDebugLine(message); if (opts.onDiagnosticLine && isServeDebugLoggingEnabled()) { opts.onDiagnosticLine(`qwen serve debug: ${message}`, 'info'); } };

Then, in this file replace every internal writeServeDebugLine(...) call inside createHttpAcpBridge’s closure with teeServeDebugLine(...). Use:

grep -n "writeServeDebugLine(" packages/acp-bridge/src/bridge.ts

to enumerate call sites — there are 6 in the current tree (lines 1410, 1423, 2242, 2328, 2624, 2637; verify with the grep). Edit each. Do NOT change the module-level writeServeDebugLine definition itself — other entry points and tests rely on it.

(Reason for not editing the top-level definition: changes the signature for all callers including tests; the closure tee is additive and locally-scoped.)

  • Step 5: Run, confirm pass

Run: cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "onDiagnosticLine" Expected: PASS. Also run full file to catch regressions: npx vitest run src/bridge.test.ts.

  • Step 6: Commit
git add packages/acp-bridge/src/bridgeOptions.ts packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridge.test.ts git commit -m "feat(acp-bridge): onDiagnosticLine sink for serve debug tee (#4548)"

Task 8: createSpawnChannelFactory with onDiagnosticLine

Files:

  • Modify: packages/acp-bridge/src/spawnChannel.ts

  • Modify: packages/acp-bridge/src/spawnChannel.test.ts (or create if missing)

  • Step 1: Inspect current export shape

grep -n "defaultSpawnChannelFactory\|onDiagnosticLine\|process.stderr.write" packages/acp-bridge/src/spawnChannel.ts | head -20

Confirm defaultSpawnChannelFactory is the only public spawn export. The existing child-stderr forwarder calls process.stderr.write(prefix + line + '\n') inside the body — locate that block (around line 125).

  • Step 2: Add failing test

In packages/acp-bridge/src/spawnChannel.test.ts (look for an existing test file; if none, create one):

import { describe, it, expect } from 'vitest'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createSpawnChannelFactory } from './spawnChannel.js'; describe('createSpawnChannelFactory onDiagnosticLine', () => { it('returns a ChannelFactory that tees child stderr lines', async () => { const captured: Array<{ line: string; level?: string }> = []; const factory = createSpawnChannelFactory({ onDiagnosticLine: (line, level) => captured.push({ line, level }), }); // Spawn a tiny child that writes to stderr then exits. Use the // QWEN_CLI_ENTRY escape hatch to point at a Node one-liner. const here = path.dirname(fileURLToPath(import.meta.url)); process.env['QWEN_CLI_ENTRY'] = path.join( here, 'testutil', 'stderrOnlyEntry.cjs', ); try { const ch = await factory('/tmp', {}); await ch.exited; // After child exit, the forwarder flushes buffered tail. expect( captured.some((e) => /\[serve pid=\d+ cwd=\/tmp\] hello-stderr/.test(e.line), ), ).toBe(true); expect( captured.every((e) => e.level === undefined || e.level === 'warn'), ).toBe(true); } finally { delete process.env['QWEN_CLI_ENTRY']; } }); });

And a fixture entry packages/acp-bridge/src/testutil/stderrOnlyEntry.cjs:

process.stderr.write('hello-stderr\n'); process.exit(0);

(Adjust if the bridge requires ACP initialize handshake before considering the child “spawned” — alternative: write the stderr line during initialize handling. If the test is too brittle, fall back to mocking the spawn and asserting the forwarder logic in isolation — read defaultSpawnChannelFactory’s body and unit-test the inner forwarder by exporting it for tests.)

  • Step 3: Run, confirm fail

Run: cd packages/acp-bridge && npx vitest run src/spawnChannel.test.ts -t "onDiagnosticLine" Expected: fail — createSpawnChannelFactory not exported.

  • Step 4: Implement createSpawnChannelFactory

Refactor defaultSpawnChannelFactory into a factory-of-factories. Replace the top of spawnChannel.ts:

export interface SpawnChannelFactoryOptions { onDiagnosticLine?: (line: string, level?: 'info' | 'warn' | 'error') => void; } export function createSpawnChannelFactory( options: SpawnChannelFactoryOptions = {}, ): ChannelFactory { const onDiagnosticLine = options.onDiagnosticLine; return async (workspaceCwd, childEnvOverrides) => { // ... existing body of defaultSpawnChannelFactory ... // Where the existing forwarder does: // process.stderr.write(prefix + line + '\n') // change it to: // const teedLine = prefix + line; // process.stderr.write(teedLine + '\n'); // if (onDiagnosticLine) onDiagnosticLine(teedLine, 'warn'); // For the [truncated] branch: // const teedTrunc = prefix + buf.slice(0, STDERR_LINE_CAP_CHARS) + ' [truncated]'; // process.stderr.write(teedTrunc + '\n'); // if (onDiagnosticLine) onDiagnosticLine(teedTrunc, 'warn'); }; } // Preserve the old export for backward compatibility (no callback wiring). export const defaultSpawnChannelFactory: ChannelFactory = createSpawnChannelFactory();

Implementation discipline:

  • Do NOT remove defaultSpawnChannelFactory — channels/IDE adapters still import it.

  • Stick to the exact existing stderr write semantics (line buffering, 64 KiB cap, truncation marker). The onDiagnosticLine call sits next to each existing process.stderr.write and never replaces it.

  • Step 5: Run, confirm pass

Run: cd packages/acp-bridge && npx vitest run src/spawnChannel.test.ts -t "onDiagnosticLine" Expected: PASS. Also npx vitest run full suite to confirm no regressions.

  • Step 6: Commit
git add packages/acp-bridge/src/spawnChannel.ts packages/acp-bridge/src/spawnChannel.test.ts packages/acp-bridge/src/testutil/stderrOnlyEntry.cjs git commit -m "feat(acp-bridge): createSpawnChannelFactory with onDiagnosticLine (#4548)"

Task 9: Route sendBridgeError through daemonLog

Files:

  • Modify: packages/cli/src/serve/server.ts

  • Modify: packages/cli/src/serve/server.test.ts

  • Step 1: Add daemonLog to createServeApp deps

Read packages/cli/src/serve/server.ts around the createServeApp signature (search for export function createServeApp or export interface ServeAppDeps). Add to its deps interface:

/** * Optional daemon logger. When provided, `sendBridgeError` routes * each route-mapped error through `daemonLog.error(...)` (which tees * to stderr + the daemon log file). When omitted, falls back to * existing stderr-only behavior. */ daemonLog?: import('./daemonLogger.js').DaemonLogger;
  • Step 2: Add failing test

In packages/cli/src/serve/server.test.ts, add (or extend a route-error test):

import { initDaemonLogger } from './daemonLogger.js'; it('sendBridgeError routes through daemonLog when provided', async () => { const tmp = mkdtempSync(path.join(os.tmpdir(), 'daemon-log-')); try { const stderr: string[] = []; const daemonLog = initDaemonLogger({ boundWorkspace: '/w', pid: 1, baseDir: tmp, stderr: (s) => stderr.push(s), }); // createServeApp signature: (opts, getPort?, deps?). daemonLog goes in deps. const app = createServeApp( /* opts */ { /* ...usual ServeOptions, copy from closest existing test... */ } as ServeOptions, /* getPort */ () => 0, /* deps */ { /* ...usual deps that make a route throw... */, daemonLog }, ); await request(app).get('/some/erroring/route').expect(500); await daemonLog.flush(); const content = readFileSync(daemonLog.getLogPath(), 'utf8'); expect(content).toMatch( /\[ERROR\] \[DAEMON\] route=GET \/some\/erroring\/route/, ); } finally { rmSync(tmp, { recursive: true, force: true }); } });

(Copy whatever route-throws-error harness already lives in server.test.ts — e.g. inject a deps stub that throws when called. The point is one route hits sendBridgeError → assertion lands in the daemon log.)

  • Step 3: Run, confirm fail

Run: cd packages/cli && npx vitest run src/serve/server.test.ts -t "daemonLog" Expected: fail.

  • Step 4: Wire sendBridgeError

In server.ts, find the sendBridgeError function (around line 2765). It currently writes to stderr inline. Refactor:

  1. Plumb daemonLog from createServeApp into the closure that owns sendBridgeError (it’s defined inside the function — same closure).
  2. At the bottom of sendBridgeError, where the stderr write happens, replace with:
if (daemonLog) { daemonLog.error( err instanceof Error ? err.message : String(err), err instanceof Error ? err : null, { ...(ctx?.route ? { route: ctx.route } : {}), ...(ctx?.sessionId ? { sessionId: ctx.sessionId } : {}), }, ); } else { // Legacy stderr-only path. Keep behavior intact for embedders that // construct createServeApp without daemonLog (tests, direct integrations). writeStderrLine( `qwen serve: ${ctx?.route ?? 'unknown route'}: ${ err instanceof Error ? (err.stack ?? err.message) : String(err) }${ctx?.sessionId ? ` sessionId=${ctx.sessionId}` : ''}`, ); }

Make sure the new branch is taken when daemonLog is non-null. daemonLog.error already tees to stderr, so the stderr line is still produced — no behavior loss.

  • Step 5: Run, confirm pass

Run: cd packages/cli && npx vitest run src/serve/server.test.ts Expected: full file PASS (new + old).

  • Step 6: Commit
git add packages/cli/src/serve/server.ts packages/cli/src/serve/server.test.ts git commit -m "feat(serve): route sendBridgeError through daemonLog (#4548)"

Task 10: Wire runQwenServe — init, boot banner, callbacks, lifecycle, shutdown flush

Files:

  • Modify: packages/cli/src/serve/runQwenServe.ts

  • Modify: packages/cli/src/serve/runQwenServe.test.ts

  • Step 1: Read the existing boot + shutdown structure

Re-read packages/cli/src/serve/runQwenServe.ts lines 590-1030 (the createHttpAcpBridge({...}) call site, the RunHandle.close body, and the onSignal handler). Note all writeStderrLine(...) calls — they’re at roughly 393, 565, 805, 821, 825, 835, 859, 865, 872, 877, 951, 961, 986, 997, 1027, 1361 (run grep -n writeStderrLine for the current line numbers).

  • Step 2: Add failing test

In packages/cli/src/serve/runQwenServe.test.ts, add (or extend):

import { existsSync, readFileSync, rmSync, mkdtempSync } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; it('runQwenServe initializes daemon logger and writes boot banner + flushes on shutdown', async () => { const tmpRuntime = mkdtempSync(path.join(os.tmpdir(), 'serve-runtime-')); const originalRuntime = process.env['QWEN_RUNTIME_DIR']; process.env['QWEN_RUNTIME_DIR'] = tmpRuntime; try { const handle = await runQwenServe({ port: 0, hostname: '127.0.0.1', mode: 'workspace', // ... fill remaining required opts from the smallest existing test ... }); // Boot wrote a daemon log somewhere under tmpRuntime/debug/daemon const daemonDir = path.join(tmpRuntime, 'debug', 'daemon'); expect(existsSync(daemonDir)).toBe(true); const logs = require('node:fs') .readdirSync(daemonDir) .filter((f: string) => f.endsWith('.log')); expect(logs.length).toBe(1); const content = readFileSync(path.join(daemonDir, logs[0]), 'utf8'); expect(content).toMatch(/daemon started pid=\d+ workspace=/); await handle.close(); // After shutdown, "shutdown signal" or equivalent should be in the log. const after = readFileSync(path.join(daemonDir, logs[0]), 'utf8'); expect(after).toMatch(/shutdown/i); } finally { if (originalRuntime === undefined) delete process.env['QWEN_RUNTIME_DIR']; else process.env['QWEN_RUNTIME_DIR'] = originalRuntime; rmSync(tmpRuntime, { recursive: true, force: true }); } });
  • Step 3: Run, confirm fail

Run: cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts -t "daemon logger" Expected: fail.

  • Step 4: Wire in runQwenServe

Edit runQwenServe.ts:

  1. Add imports near the existing ones:
import { initDaemonLogger, type DaemonLogger } from './daemonLogger.js'; import { createSpawnChannelFactory } from '@qwen-code/acp-bridge/spawnChannel';
  1. Inside runQwenServe(opts), right after boundWorkspace is canonicalized (find the assignment; it’s the value passed to createHttpAcpBridge):
const daemonLog: DaemonLogger = initDaemonLogger({ boundWorkspace }); writeStderrLine( `qwen serve: daemon log → ${daemonLog.getLogPath() || '(disabled)'}`, );
  1. Update the createHttpAcpBridge({...}) call (around line 606):
const channelFactory = createSpawnChannelFactory({ onDiagnosticLine: (line, level) => daemonLog.raw(line, level), }); const bridge = deps.bridge ?? createHttpAcpBridge({ // ... existing fields ... channelFactory, onDiagnosticLine: (line, level) => daemonLog.raw(line, level), });

(If deps.bridge is provided, the operator is embedding and owns their own wiring — skip the callback.)

  1. Update the createServeApp(...) call (currently at runQwenServe.ts:706, signature is createServeApp(opts, getPort, deps)) to add daemonLog to the deps object:
const app = createServeApp(opts, () => actualPort, { bridge, boundWorkspace, fsFactory, daemonLog, });
  1. Replace lifecycle-only writeStderrLine(...) calls (the ones inside onSignal, the bridge.shutdown error path, the server error listener, the device-flow dispose error, the “received signal, draining” line) with daemonLog.warn(...) / daemonLog.error(..., err) — daemonLog tees to stderr so operator-visible output is preserved. Do NOT touch:

    • Boot banner about “listening on URL” (that one is stdout, not stderr — writeStdoutLine).
    • CLI usage/argparse errors before daemonLog is constructed.
    • The lone “qwen serve: daemon log → …” banner added in step 2 (avoid logging a line about itself).

    To be concrete, the mechanical rule for this step: every writeStderrLine call after the daemonLog is constructed and before process.exit is candidate; if its content reads like a daemon diagnostic (not a one-shot startup banner), switch it.

  2. In the RunHandle.close body, after the finish callback runs (or right before process.exit(0) in onSignal), add await daemonLog.flush();. Concretely, the onSignal handler becomes:

const onSignal = async (signal: NodeJS.Signals) => { if (shuttingDown) { /* unchanged */ return; } daemonLog.warn(`received ${signal}, draining`, { signal }); try { await handle.close(); await daemonLog.flush(); process.exit(0); } catch (err) { daemonLog.error('shutdown error', err instanceof Error ? err : null); await daemonLog.flush().catch(() => {}); process.exit(1); } };
  • Step 5: Run, confirm pass

Run: cd packages/cli && npx vitest run src/serve/runQwenServe.test.ts Expected: full file PASS.

Run also: cd packages/cli && npx vitest run src/serve/ (full serve dir, catches indirect regressions like server.test.ts assertions on stderr output).

  • Step 6: Commit
git add packages/cli/src/serve/runQwenServe.ts packages/cli/src/serve/runQwenServe.test.ts git commit -m "feat(serve): init daemonLogger in runQwenServe + flush on shutdown (#4548)"

Task 11: Documentation

Files:

  • Modify: existing serve docs (locate with find docs -iname '*serve*' and ls docs/cli/)

  • Step 1: Find the right doc

find docs -iname '*serve*' -type f ls docs/cli/ 2>/dev/null

Pick the most natural home — likely docs/cli/serve.md. If none exists for qwen serve, create docs/cli/serve-daemon-log.md.

  • Step 2: Write the section

Add (or create) a “Daemon log file” section:

## Daemon log file `qwen serve` writes a per-process diagnostic log to:

${QWEN_RUNTIME_DIR or ~/.qwen}/debug/daemon/serve--.log

A `latest` symlink in the same directory always points at the current process's log, so `tail -f ~/.qwen/debug/daemon/latest` will follow whichever daemon is running. The log captures lifecycle messages, route errors (with `route=` and `sessionId=` context), ACP child stderr, and — when `QWEN_SERVE_DEBUG=1` is set — extra bridge breadcrumbs. Lines that go to stderr today still go to stderr; the file log is **additive**, not a replacement. ### Disabling Set `QWEN_DAEMON_LOG_FILE=0` (or `false`/`off`/`no`) to skip file logging entirely. Stderr output is unaffected. ### Relation to session debug logs Session-scoped debug logs (`~/.qwen/debug/<sessionId>.txt` and the `~/.qwen/debug/latest` symlink) are independent. The daemon log lives in a sibling `daemon/` subdirectory; per-session debug semantics are unchanged by this feature. ### No rotation The daemon log appends indefinitely. Rotate manually if it grows large. A future enhancement may add automatic rotation; track via #4548 follow-ups.
  • Step 3: Commit
git add docs/cli/serve.md # or the actual file path git commit -m "docs(serve): document daemon log file path and opt-out (#4548)"

Task 12: Final verification

  • Step 1: Full test sweep
cd /Users/jinye.djy/Projects/qwen-code/.claude/worktrees/feat-support-daemon-logger npm run test --workspace=packages/acp-bridge npm run test --workspace=packages/cli

Expected: all green.

  • Step 2: Typecheck
npm run typecheck --workspace=packages/acp-bridge npm run typecheck --workspace=packages/cli

Expected: no errors.

  • Step 3: Manual smoke
QWEN_RUNTIME_DIR=$(mktemp -d) node packages/cli/dist/index.js serve --port 0 --hostname 127.0.0.1 & SERVE_PID=$! sleep 1 ls $QWEN_RUNTIME_DIR/debug/daemon/ cat $QWEN_RUNTIME_DIR/debug/daemon/latest kill -TERM $SERVE_PID wait $SERVE_PID 2>/dev/null || true cat $QWEN_RUNTIME_DIR/debug/daemon/latest # should now contain shutdown line

Expected: log file exists, contains daemon started ..., then after kill the received SIGTERM, draining line.

If packages/cli/dist/index.js doesn’t exist, build first: npm run build --workspace=packages/cli.

  • Step 4: Open PR
git push -u origin HEAD gh pr create --title "feat(serve): add daemon file logger (#4548)" --body "$(cat <<'EOF' ## Summary - Adds a per-process daemon file logger at `~/.qwen/debug/daemon/serve-<pid>-<workspaceHash>.log` (configurable via `QWEN_RUNTIME_DIR`, opt-out via `QWEN_DAEMON_LOG_FILE=0`). - Routes `runQwenServe` lifecycle messages, `sendBridgeError` route errors, `writeServeDebugLine` debug breadcrumbs, and ACP child stderr into the daemon log without removing existing stderr output. - Adds `BridgeOptions.onDiagnosticLine` and `createSpawnChannelFactory({ onDiagnosticLine })` to keep `acp-bridge` ignorant of cli. Closes #4548. ## Test plan - [x] New unit tests in `packages/cli/src/serve/daemonLogger.test.ts` cover formatter, file init, info/warn/error, raw, latest symlink, opt-out, degraded fallback. - [x] `packages/acp-bridge/src/bridge.test.ts` covers `onDiagnosticLine` tee from `writeServeDebugLine`. - [x] `packages/acp-bridge/src/spawnChannel.test.ts` covers child stderr forwarder. - [x] `packages/cli/src/serve/server.test.ts` covers route-error routing through `daemonLog.error`. - [x] `packages/cli/src/serve/runQwenServe.test.ts` covers boot banner + flush on shutdown. - [x] Manual smoke: log file created at boot, contains shutdown line on SIGTERM. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) EOF )"

Self-review notes

  • Spec coverage: §3 module table covered by Tasks 1-10. §4 daemon-id + path → Task 3. §5 API surface → Tasks 1-6. §6 format + tee semantics → Task 1 (format), Task 4 (info/warn/error tee), Task 5 (raw file-only). §7 boot/shutdown → Task 10. §8 coverage table → Tasks 7/8/9/10. §9 write path & flush → Task 4. §10 config → Task 2 (opt-out), Task 11 (docs). §11 error handling → Tasks 3, 4. §12 testing → distributed across tasks. §13 docs → Task 11. §15 acceptance criteria → met by Tasks 3, 9, 8, 10, 10, 11 respectively.

  • Trace context (§6 bullet): deferred. The spec leaves it explicit (“Helper extracted to a shared module … or duplicated locally — leave to plan”). The current plan does NOT inject trace_id/span_id; that is a follow-up task tracked in §16. If reviewer pushes back, add a Task 4.5 that imports trace from @opentelemetry/api and folds the span context into buildDaemonLogLine — but only if the reviewer asks; YAGNI otherwise.

  • updateSymlink import path: Task 6 step 3 hedges on whether updateSymlink is exported from @qwen-code/qwen-code-core. Verify before editing: grep -n updateSymlink packages/core/src/index.ts. If missing, add the re-export in the same commit as Task 6.

  • acp-bridge test for createSpawnChannelFactory: spawning a real child in a unit test is brittle. If Task 8 step 2 turns out to be flaky in CI, the fallback is to refactor the inner stderr forwarder into a small exported helper (forwardChildStderr(stream, { prefix, onLine })) and unit-test that in isolation — no real spawn needed.

Last updated on