Initial commit: QwenClaw persistent daemon for Qwen Code

This commit is contained in:
admin
2026-02-26 02:16:18 +04:00
Unverified
commit 80cdad994c
53 changed files with 7285 additions and 0 deletions

327
src/runner.ts Normal file
View File

@@ -0,0 +1,327 @@
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 = "<!-- QWENCLAW_MANAGED_BLOCK_START -->";
const QWENCLAW_BLOCK_END = "<!-- QWENCLAW_MANAGED_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<unknown> = Promise.resolve();
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
const task = queue.then(fn, fn);
queue = task.catch((): Promise<unknown> => 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<string, string>,
model: string,
api: string
): Record<string, string> {
const childEnv: Record<string, string> = { ...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<string, string>
): Promise<{ rawStdout: string; stderr: string; exitCode: number }> {
const args = [...baseArgs];
if (model.trim()) args.push("--model", model.trim());
const proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "pipe",
env: buildChildEnv(baseEnv, model, api),
});
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<void> {
// 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<string> {
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<string> {
try {
const content = await Bun.file(HEARTBEAT_PROMPT_FILE).text();
return content.trim();
} catch {
return "";
}
}
async function execQwen(name: string, prompt: string): Promise<RunResult> {
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<string, string>;
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<RunResult> {
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<RunResult> {
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<void> {
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.`);
}