Initial commit: QwenClaw persistent daemon for Qwen Code
This commit is contained in:
327
src/runner.ts
Normal file
327
src/runner.ts
Normal 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.`);
|
||||
}
|
||||
Reference in New Issue
Block a user