import { mkdir, readFile, writeFile } from "fs/promises"; import { join } from "path"; import { existsSync } from "fs"; import { getSession, createSession } from "./sessions"; import { getSettings, type ModelConfig, type SecurityConfig } from "./config"; import { buildClockPromptPrefix } from "./timezone"; const LOGS_DIR = join(process.cwd(), ".qwen/qwenclaw/logs"); // Resolve prompts relative to the qwenclaw installation, not the project dir const PROMPTS_DIR = join(import.meta.dir, "..", "prompts"); const HEARTBEAT_PROMPT_FILE = join(PROMPTS_DIR, "heartbeat", "HEARTBEAT.md"); const PROJECT_QWEN_MD = join(process.cwd(), "QWEN.md"); const LEGACY_PROJECT_QWEN_MD = join(process.cwd(), ".qwen", "QWEN.md"); const QWENCLAW_BLOCK_START = ""; const QWENCLAW_BLOCK_END = ""; export interface RunResult { stdout: string; stderr: string; exitCode: number; } const RATE_LIMIT_PATTERN = /you(?:'|')ve hit your limit/i; // Serial queue — prevents concurrent --resume on the same session let queue: Promise = Promise.resolve(); function enqueue(fn: () => Promise): Promise { const task = queue.then(fn, fn); queue = task.catch((): Promise => Promise.resolve()); return task; } function extractRateLimitMessage(stdout: string, stderr: string): string | null { const candidates = [stdout, stderr]; for (const text of candidates) { const trimmed = text.trim(); if (trimmed && RATE_LIMIT_PATTERN.test(trimmed)) return trimmed; } return null; } function sameModelConfig(a: ModelConfig, b: ModelConfig): boolean { return a.model.trim().toLowerCase() === b.model.trim().toLowerCase() && a.api.trim() === b.api.trim(); } function hasModelConfig(value: ModelConfig): boolean { return value.model.trim().length > 0 || value.api.trim().length > 0; } function buildChildEnv( baseEnv: Record, model: string, api: string ): Record { const childEnv: Record = { ...baseEnv }; if (api.trim()) childEnv.ANTHROPIC_AUTH_TOKEN = api.trim(); // Add any Qwen-specific environment variables here return childEnv; } async function runQwenOnce( baseArgs: string[], model: string, api: string, baseEnv: Record ): Promise<{ rawStdout: string; stderr: string; exitCode: number }> { const args = [...baseArgs]; if (model.trim()) args.push("--model", model.trim()); // Cross-platform command detection const qwenCommand = process.platform === "win32" ? "qwen.cmd" : process.platform === "darwin" ? "qwen" : "qwen"; const proc = Bun.spawn([qwenCommand, ...args], { stdout: "pipe", stderr: "pipe", env: buildChildEnv(baseEnv, model, api), shell: true, // Detach on non-Windows to allow background execution detached: process.platform !== "win32", }); const [rawStdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); await proc.exited; return { rawStdout, stderr, exitCode: proc.exitCode ?? 1 }; } const PROJECT_DIR = process.cwd(); const DIR_SCOPE_PROMPT = [ `CRITICAL SECURITY CONSTRAINT: You are scoped to the project directory: ${PROJECT_DIR}`, "You MUST NOT read, write, edit, or delete any file outside this directory.", "You MUST NOT run bash commands that modify anything outside this directory (no cd /, no /etc, no ~/, no ../.. escapes).", "If a request requires accessing files outside the project, refuse and explain why.", ].join("\n"); export async function ensureProjectQwenMd(): Promise { // Preflight-only initialization: never rewrite an existing project QWEN.md. if (existsSync(PROJECT_QWEN_MD)) return; const promptContent = (await loadPrompts()).trim(); const managedBlock = [ QWENCLAW_BLOCK_START, promptContent, QWENCLAW_BLOCK_END, ].join("\n"); let content = ""; if (existsSync(LEGACY_PROJECT_QWEN_MD)) { try { const legacy = await readFile(LEGACY_PROJECT_QWEN_MD, "utf8"); content = legacy.trim(); } catch (e) { console.error(`[${new Date().toLocaleTimeString()}] Failed to read legacy .qwen/QWEN.md:`, e); return; } } const normalized = content.trim(); const hasManagedBlock = normalized.includes(QWENCLAW_BLOCK_START) && normalized.includes(QWENCLAW_BLOCK_END); const managedPattern = new RegExp( `${QWENCLAW_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${QWENCLAW_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "m" ); const merged = hasManagedBlock ? `${normalized.replace(managedPattern, managedBlock)}\n` : normalized ? `${normalized}\n\n${managedBlock}\n` : `${managedBlock}\n`; try { await writeFile(PROJECT_QWEN_MD, merged, "utf8"); } catch (e) { console.error(`[${new Date().toLocaleTimeString()}] Failed to write project QWEN.md:`, e); } } function buildSecurityArgs(security: SecurityConfig): string[] { const args: string[] = []; // Qwen-specific security flags - adjust based on Qwen's CLI options if (security.allowedTools.length > 0) { args.push("--allowed-tools", security.allowedTools.join(",")); } if (security.disallowedTools.length > 0) { args.push("--disallowed-tools", security.disallowedTools.join(",")); } return args; } /** Load and concatenate all prompt files from the prompts/ directory. */ async function loadPrompts(): Promise { const selectedPromptFiles = [ join(PROMPTS_DIR, "IDENTITY.md"), join(PROMPTS_DIR, "USER.md"), join(PROMPTS_DIR, "SOUL.md"), ]; const parts: string[] = []; for (const file of selectedPromptFiles) { try { const content = await Bun.file(file).text(); if (content.trim()) parts.push(content.trim()); } catch (e) { console.error(`[${new Date().toLocaleTimeString()}] Failed to read prompt file ${file}:`, e); } } return parts.join("\n\n"); } export async function loadHeartbeatPromptTemplate(): Promise { try { const content = await Bun.file(HEARTBEAT_PROMPT_FILE).text(); return content.trim(); } catch { return ""; } } async function execQwen(name: string, prompt: string): Promise { await mkdir(LOGS_DIR, { recursive: true }); const existing = await getSession(); const isNew = !existing; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const logFile = join(LOGS_DIR, `${name}-${timestamp}.log`); const { security, model, api, fallback } = getSettings(); const primaryConfig: ModelConfig = { model, api }; const fallbackConfig: ModelConfig = { model: fallback?.model ?? "", api: fallback?.api ?? "", }; const securityArgs = buildSecurityArgs(security); console.log( `[${new Date().toLocaleTimeString()}] Running: ${name} (${isNew ? "new session" : `resume ${existing.sessionId.slice(0, 8)}`}, security: ${security.level})` ); // Build the base command for Qwen const args = [ "qwen", "-p", prompt, ...securityArgs, ]; if (!isNew) { args.push("--resume", existing.sessionId); } // Build the appended system prompt: prompt files + directory scoping const promptContent = await loadPrompts(); const appendParts: string[] = ["You are running inside QwenClaw."]; if (promptContent) appendParts.push(promptContent); // Load the project's QWEN.md if it exists if (existsSync(PROJECT_QWEN_MD)) { try { const qwenMd = await Bun.file(PROJECT_QWEN_MD).text(); if (qwenMd.trim()) appendParts.push(qwenMd.trim()); } catch (e) { console.error(`[${new Date().toLocaleTimeString()}] Failed to read project QWEN.md:`, e); } } if (security.level !== "unrestricted") appendParts.push(DIR_SCOPE_PROMPT); if (appendParts.length > 0) { args.push("--system-prompt", appendParts.join("\n\n")); } // Strip any nested env vars const { QWEN_CODE: _, ...cleanEnv } = process.env; const baseEnv = { ...cleanEnv } as Record; let exec = await runQwenOnce(args, primaryConfig.model, primaryConfig.api, baseEnv); const primaryRateLimit = extractRateLimitMessage(exec.rawStdout, exec.stderr); let usedFallback = false; if ( primaryRateLimit && hasModelConfig(fallbackConfig) && !sameModelConfig(primaryConfig, fallbackConfig) ) { console.warn( `[${new Date().toLocaleTimeString()}] Qwen limit reached; retrying with fallback${fallbackConfig.model ? ` (${fallbackConfig.model})` : ""}...` ); exec = await runQwenOnce(args, fallbackConfig.model, fallbackConfig.api, baseEnv); usedFallback = true; } const rawStdout = exec.rawStdout; const stderr = exec.stderr; const exitCode = exec.exitCode; let stdout = rawStdout; let sessionId = existing?.sessionId ?? "unknown"; const rateLimitMessage = extractRateLimitMessage(rawStdout, stderr); if (rateLimitMessage) { stdout = rateLimitMessage; } // For new sessions, try to capture session ID if Qwen provides one if (!rateLimitMessage && isNew && exitCode === 0) { // Try to parse session ID from output or use a generated one sessionId = `qwenclaw-${Date.now()}`; await createSession(sessionId); console.log(`[${new Date().toLocaleTimeString()}] Session created: ${sessionId}`); } const result: RunResult = { stdout, stderr, exitCode }; const output = [ `# ${name}`, `Date: ${new Date().toISOString()}`, `Session: ${sessionId} (${isNew ? "new" : "resumed"})`, `Model config: ${usedFallback ? "fallback" : "primary"}`, `Prompt: ${prompt}`, `Exit code: ${result.exitCode}`, "", "## Output", stdout, ...(stderr ? ["## Stderr", stderr] : []), ].join("\n"); await Bun.write(logFile, output); console.log(`[${new Date().toLocaleTimeString()}] Done: ${name} → ${logFile}`); return result; } export async function run(name: string, prompt: string): Promise { return enqueue(() => execQwen(name, prompt)); } function prefixUserMessageWithClock(prompt: string): string { try { const settings = getSettings(); const prefix = buildClockPromptPrefix(new Date(), settings.timezoneOffsetMinutes); return `${prefix}\n${prompt}`; } catch { const prefix = buildClockPromptPrefix(new Date(), 0); return `${prefix}\n${prompt}`; } } export async function runUserMessage(name: string, prompt: string): Promise { return run(name, prefixUserMessageWithClock(prompt)); } /** * Bootstrap the session: fires Qwen with the system prompt so the * session is created immediately. No-op if a session already exists. */ export async function bootstrap(): Promise { const existing = await getSession(); if (existing) return; console.log(`[${new Date().toLocaleTimeString()}] Bootstrapping new session...`); await execQwen("bootstrap", "Wakeup, my friend!"); console.log(`[${new Date().toLocaleTimeString()}] Bootstrap complete — session is live.`); }