Fix: Daemon startup and Qwen CLI path for Windows

This commit is contained in:
admin
2026-02-26 02:28:06 +04:00
Unverified
parent 80cdad994c
commit c9799c1eac
6 changed files with 358 additions and 180 deletions

View File

@@ -14,7 +14,7 @@ import type { Job } from "../jobs";
const QWEN_DIR = join(process.cwd(), ".qwen");
const HEARTBEAT_DIR = join(QWEN_DIR, "qwenclaw");
const STATUSLINE_FILE = join(QWEN_DIR, "statusline.cjs");
const QWEN_SETTINGS_FILE = join(QWEN_DIR, "settings.json");
const QWEN_SETTINGS_FILE = join(HEARTBEAT_DIR, "settings.json");
const PREFLIGHT_SCRIPT = fileURLToPath(new URL("../preflight.ts", import.meta.url));
// --- Statusline setup/teardown ---
@@ -22,13 +22,16 @@ const PREFLIGHT_SCRIPT = fileURLToPath(new URL("../preflight.ts", import.meta.ur
const STATUSLINE_SCRIPT = `#!/usr/bin/env node
const { readFileSync } = require("fs");
const { join } = require("path");
const DIR = join(__dirname, "qwenclaw");
const STATE_FILE = join(DIR, "state.json");
const PID_FILE = join(DIR, "daemon.pid");
const R = "\\x1b[0m";
const DIM = "\\x1b[2m";
const RED = "\\x1b[31m";
const GREEN = "\\x1b[32m";
function fmt(ms) {
if (ms <= 0) return GREEN + "now!" + R;
var s = Math.floor(ms / 1000);
@@ -38,65 +41,58 @@ function fmt(ms) {
if (m > 0) return m + "m";
return (s % 60) + "s";
}
function alive() {
try {
var pid = readFileSync(PID_FILE, "utf-8").trim();
process.kill(Number(pid), 0);
return true;
} catch {
return false;
}
} catch { return false; }
}
var B = DIM + "\\u2502" + R;
var TL = DIM + "\\u256d" + R;
var TR = DIM + "\\u256e" + R;
var BL = DIM + "\\u2570" + R;
var BR = DIM + "\\u256f" + R;
var H = DIM + "\\u2500" + R;
var HEADER = TL + H.repeat(6) + " \\ud83d\\udc3e QwenClaw \\ud83d\\udc3e " + H.repeat(6) + TR;
var HEADER = TL + H.repeat(6) + " \\ud83e\\udd9e qwenclaw \\ud83e\\udd9e " + H.repeat(6) + TR;
var FOOTER = BL + H.repeat(30) + BR;
if (!alive()) {
process.stdout.write(
HEADER +
"\\n" +
B +
" " +
RED +
"\\u25cb offline" +
R +
" " +
B +
"\\n" +
FOOTER
HEADER + "\\n" +
B + " " + RED + "\\u25cb offline" + R + " " + B + "\\n" +
FOOTER
);
process.exit(0);
}
try {
var state = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
var now = Date.now();
var info = [];
if (state.heartbeat) {
info.push("\\ud83d\\udc93 " + fmt(state.heartbeat.nextAt - now));
}
var jc = (state.jobs || []).length;
info.push("\\ud83d\\udccb " + jc + " job" + (jc !== 1 ? "s" : ""));
info.push(GREEN + "\\u25cf live" + R);
if (state.telegram) {
info.push(GREEN + "\\ud83d\\udce1" + R);
}
var mid = " " + info.join(" " + B + " ") + " ";
process.stdout.write(HEADER + "\\n" + B + mid + B + "\\n" + FOOTER);
} catch {
process.stdout.write(
HEADER +
"\\n" +
B +
DIM +
" waiting... " +
R +
B +
"\\n" +
FOOTER
HEADER + "\\n" +
B + DIM + " waiting... " + R + B + "\\n" +
FOOTER
);
}
`;
@@ -109,37 +105,23 @@ function parseClockMinutes(value: string): number | null {
return Number(match[1]) * 60 + Number(match[2]);
}
function isHeartbeatExcludedNow(
config: HeartbeatConfig,
timezoneOffsetMinutes: number
): boolean {
function isHeartbeatExcludedNow(config: HeartbeatConfig, timezoneOffsetMinutes: number): boolean {
return isHeartbeatExcludedAt(config, timezoneOffsetMinutes, new Date());
}
function isHeartbeatExcludedAt(
config: HeartbeatConfig,
timezoneOffsetMinutes: number,
at: Date
): boolean {
if (!Array.isArray(config.excludeWindows) || config.excludeWindows.length === 0)
return false;
function isHeartbeatExcludedAt(config: HeartbeatConfig, timezoneOffsetMinutes: number, at: Date): boolean {
if (!Array.isArray(config.excludeWindows) || config.excludeWindows.length === 0) return false;
const local = getDayAndMinuteAtOffset(at, timezoneOffsetMinutes);
for (const window of config.excludeWindows) {
const start = parseClockMinutes(window.start);
const end = parseClockMinutes(window.end);
if (start == null || end == null) continue;
const days =
Array.isArray(window.days) && window.days.length > 0
? window.days
: ALL_DAYS;
const days = Array.isArray(window.days) && window.days.length > 0 ? window.days : ALL_DAYS;
const sameDay = start < end;
if (sameDay) {
if (days.includes(local.day) && local.minute >= start && local.minute < end)
return true;
if (days.includes(local.day) && local.minute >= start && local.minute < end) return true;
continue;
}
@@ -165,33 +147,40 @@ function nextAllowedHeartbeatAt(
const interval = Math.max(60_000, Math.round(intervalMs));
let candidate = fromMs + interval;
let guard = 0;
while (
isHeartbeatExcludedAt(config, timezoneOffsetMinutes, new Date(candidate)) &&
guard < 20_000
) {
while (isHeartbeatExcludedAt(config, timezoneOffsetMinutes, new Date(candidate)) && guard < 20_000) {
candidate += interval;
guard++;
}
return candidate;
}
async function setupStatusline() {
console.log("[QwenClaw SetupStatusline] Starting...");
await mkdir(QWEN_DIR, { recursive: true });
console.log("[QwenClaw SetupStatusline] Directory created:", QWEN_DIR);
await writeFile(STATUSLINE_FILE, STATUSLINE_SCRIPT);
console.log("[QwenClaw SetupStatusline] Statusline script written:", STATUSLINE_FILE);
let settings: Record<string, unknown> = {};
try {
settings = await Bun.file(QWEN_SETTINGS_FILE).json();
} catch {
// file doesn't exist or isn't valid JSON
const settingsText = await Bun.file(QWEN_SETTINGS_FILE).text();
console.log("[QwenClaw SetupStatusline] Settings text read, length:", settingsText.length);
settings = JSON.parse(settingsText);
console.log("[QwenClaw SetupStatusline] Settings parsed");
} catch (err) {
console.log("[QwenClaw SetupStatusline] No existing settings or error:", err);
}
settings.statusLine = {
type: "command",
command: "node .qwen/statusline.cjs",
};
await writeFile(QWEN_SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
const settingsJson = JSON.stringify(settings, null, 2) + "\n";
console.log("[QwenClaw SetupStatusline] Writing settings:", settingsJson.substring(0, 100));
await writeFile(QWEN_SETTINGS_FILE, settingsJson);
console.log("[QwenClaw SetupStatusline] Settings written");
}
async function teardownStatusline() {
@@ -213,6 +202,10 @@ async function teardownStatusline() {
// --- Main ---
export async function start(args: string[] = []) {
console.log("[QwenClaw Start] Function called with args:", args);
try {
let hasPromptFlag = false;
let hasTriggerFlag = false;
let telegramFlag = false;
@@ -220,9 +213,9 @@ export async function start(args: string[] = []) {
let webFlag = false;
let replaceExistingFlag = false;
let webPortFlag: number | null = null;
const payloadParts: string[] = [];
console.log("[QwenClaw Start] Parsing arguments...");
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--prompt") {
@@ -254,46 +247,39 @@ export async function start(args: string[] = []) {
payloadParts.push(arg);
}
}
const payload = payloadParts.join(" ").trim();
console.log("[QwenClaw Start] Payload:", payload, "hasPromptFlag:", hasPromptFlag, "webFlag:", webFlag);
if (hasPromptFlag && !payload) {
console.error(
"Usage: qwenclaw start --prompt [--trigger] [--telegram] [--debug] [--web] [--web-port <port>] [--replace-existing]"
);
console.error("Usage: qwenclaw start --prompt <prompt> [--trigger] [--telegram] [--debug] [--web] [--web-port <port>] [--replace-existing]");
process.exit(1);
}
if (!hasPromptFlag && payload) {
console.error("Prompt text requires `--prompt`.");
process.exit(1);
}
if (telegramFlag && !hasTriggerFlag) {
console.error("`--telegram` with `start` requires `--trigger`.");
process.exit(1);
}
if (hasPromptFlag && !hasTriggerFlag && (webFlag || webPortFlag !== null)) {
console.error("`--web` is daemon-only. Remove `--prompt`, or add `--trigger`.");
process.exit(1);
}
console.log("[QwenClaw Start] Checking existing daemon...");
// One-shot mode: explicit prompt without trigger.
if (hasPromptFlag && !hasTriggerFlag) {
const existingPid = await checkExistingDaemon();
if (existingPid) {
console.error(
`\x1b[31mAborted: daemon already running in this directory (PID ${existingPid})\x1b[0m`
);
console.error("Use `qwenclaw send [--telegram]` while daemon is running.");
console.error(`\x1b[31mAborted: daemon already running in this directory (PID ${existingPid})\x1b[0m`);
console.error("Use `qwenclaw send <message> [--telegram]` while daemon is running.");
process.exit(1);
}
await initConfig();
await loadSettings();
await ensureProjectQwenMd();
const result = await runUserMessage("prompt", payload);
console.log(result.stdout);
if (result.exitCode !== 0) process.exit(result.exitCode);
@@ -303,18 +289,18 @@ export async function start(args: string[] = []) {
const existingPid = await checkExistingDaemon();
if (existingPid) {
if (!replaceExistingFlag) {
console.error(
`\x1b[31mAborted: daemon already running in this directory (PID ${existingPid})\x1b[0m`
);
console.error(`\x1b[31mAborted: daemon already running in this directory (PID ${existingPid})\x1b[0m`);
console.error(`Use --stop first, or kill PID ${existingPid} manually.`);
process.exit(1);
}
console.log(`Replacing existing daemon (PID ${existingPid})...`);
try {
process.kill(existingPid, "SIGTERM");
} catch {
// ignore if process is already dead
}
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
try {
@@ -324,20 +310,36 @@ export async function start(args: string[] = []) {
break;
}
}
await cleanupPidFile();
}
await initConfig();
console.log("[QwenClaw Start] Config initialized");
const settings = await loadSettings();
console.log("[QwenClaw Start] Settings loaded, web:", settings.web);
await ensureProjectQwenMd();
console.log("[QwenClaw Start] Project QWEN.md ensured");
const jobs = await loadJobs();
console.log("[QwenClaw Start] Jobs loaded:", jobs.length);
const webEnabled = webFlag || webPortFlag !== null || settings.web.enabled;
const webPort = webPortFlag ?? settings.web.port;
console.log("[QwenClaw Start] Web enabled:", webEnabled, "Port:", webPort);
await setupStatusline();
try {
await setupStatusline();
console.log("[QwenClaw Start] Statusline setup complete");
} catch (err) {
console.error("[QwenClaw Start] Statusline setup error:", err);
}
await writePidFile();
console.log("[QwenClaw Start] PID file written");
let web: WebServerHandle | null = null;
async function shutdown() {
@@ -346,23 +348,18 @@ export async function start(args: string[] = []) {
await cleanupPidFile();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
console.log("QwenClaw daemon started");
console.log("qwenclaw daemon started");
console.log(` PID: ${process.pid}`);
console.log(` Security: ${settings.security.level}`);
if (settings.security.allowedTools.length > 0)
console.log(` + allowed: ${settings.security.allowedTools.join(", ")}`);
if (settings.security.disallowedTools.length > 0)
console.log(` - blocked: ${settings.security.disallowedTools.join(", ")}`);
console.log(
` Heartbeat: ${settings.heartbeat.enabled ? `every ${settings.heartbeat.interval}m` : "disabled"}`
);
console.log(
` Web UI: ${webEnabled ? `http://${settings.web.host}:${webPort}` : "disabled"}`
);
console.log(` Heartbeat: ${settings.heartbeat.enabled ? `every ${settings.heartbeat.interval}m` : "disabled"}`);
console.log(` Web UI: ${webEnabled ? `http://${settings.web.host}:${webPort}` : "disabled"}`);
if (debugFlag) console.log(" Debug: enabled");
console.log(` Jobs loaded: ${jobs.length}`);
jobs.forEach((j) => console.log(` - ${j.name} [${j.schedule}]`));
@@ -427,41 +424,32 @@ export async function start(args: string[] = []) {
},
onHeartbeatSettingsChanged: (patch) => {
let changed = false;
if (
typeof patch.enabled === "boolean" &&
currentSettings.heartbeat.enabled !== patch.enabled
) {
if (typeof patch.enabled === "boolean" && currentSettings.heartbeat.enabled !== patch.enabled) {
currentSettings.heartbeat.enabled = patch.enabled;
changed = true;
}
if (
typeof patch.interval === "number" &&
Number.isFinite(patch.interval)
) {
if (typeof patch.interval === "number" && Number.isFinite(patch.interval)) {
const interval = Math.max(1, Math.min(1440, Math.round(patch.interval)));
if (currentSettings.heartbeat.interval !== interval) {
currentSettings.heartbeat.interval = interval;
changed = true;
}
}
if (
typeof patch.prompt === "string" &&
currentSettings.heartbeat.prompt !== patch.prompt
) {
currentSettings.heartbeat.prompt = patch.prompt;
if (typeof patch.prompt === "string" && currentSettings.heartbeat.prompt !== patch.prompt) {
currentSettings.heartbeat.prompt = patch.prompt;
changed = true;
}
if (Array.isArray(patch.excludeWindows)) {
const prev = JSON.stringify(currentSettings.heartbeat.excludeWindows);
const next = JSON.stringify(patch.excludeWindows);
if (prev !== next) {
currentSettings.heartbeat.excludeWindows = patch.excludeWindows;
changed = true;
}
if (Array.isArray(patch.excludeWindows)) {
const prev = JSON.stringify(currentSettings.heartbeat.excludeWindows);
const next = JSON.stringify(patch.excludeWindows);
if (prev !== next) {
currentSettings.heartbeat.excludeWindows = patch.excludeWindows;
changed = true;
}
}
if (!changed) return;
scheduleHeartbeat();
updateState();
}
if (!changed) return;
scheduleHeartbeat();
updateState();
console.log(`[${ts()}] Heartbeat settings updated from Web UI`);
},
onJobsChanged: async () => {
@@ -476,6 +464,7 @@ export async function start(args: string[] = []) {
if (!isAddrInUse(err) || i === maxAttempts - 1) throw err;
}
}
throw lastError;
}
@@ -483,26 +472,19 @@ export async function start(args: string[] = []) {
currentSettings.web.enabled = true;
web = startWebWithFallback(currentSettings.web.host, webPort);
currentSettings.web.port = web.port;
console.log(
`[${new Date().toLocaleTimeString()}] Web UI listening on http://${web.host}:${web.port}`
);
console.log(`[${new Date().toLocaleTimeString()}] Web UI listening on http://${web.host}:${web.port}`);
}
// --- Helpers ---
function ts() {
return new Date().toLocaleTimeString();
}
function ts() { return new Date().toLocaleTimeString(); }
function startPreflightInBackground(projectPath: string): void {
try {
const proc = Bun.spawn(
[process.execPath, "run", PREFLIGHT_SCRIPT, projectPath],
{
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}
);
const proc = Bun.spawn([process.execPath, "run", PREFLIGHT_SCRIPT, projectPath], {
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
});
proc.unref();
console.log(`[${ts()}] Plugin preflight started in background`);
} catch (err) {
@@ -510,15 +492,11 @@ export async function start(args: string[] = []) {
}
}
function forwardToTelegram(
label: string,
result: { exitCode: number; stdout: string; stderr: string }
) {
function forwardToTelegram(label: string, result: { exitCode: number; stdout: string; stderr: string }) {
if (!telegramSend || currentSettings.telegram.allowedUserIds.length === 0) return;
const text =
result.exitCode === 0
? `${label ? `[${label}]\n` : ""}${result.stdout || "(empty)"}`
: `${label ? `[${label}] ` : ""}error (exit ${result.exitCode}): ${result.stderr || "Unknown"}`;
const text = result.exitCode === 0
? `${label ? `[${label}]\n` : ""}${result.stdout || "(empty)"}`
: `${label ? `[${label}] ` : ""}error (exit ${result.exitCode}): ${result.stderr || "Unknown"}`;
for (const userId of currentSettings.telegram.allowedUserIds) {
telegramSend(userId, text).catch((err) =>
console.error(`[Telegram] Failed to forward to ${userId}: ${err}`)
@@ -545,12 +523,7 @@ export async function start(args: string[] = []) {
);
function tick() {
if (
isHeartbeatExcludedNow(
currentSettings.heartbeat,
currentSettings.timezoneOffsetMinutes
)
) {
if (isHeartbeatExcludedNow(currentSettings.heartbeat, currentSettings.timezoneOffsetMinutes)) {
console.log(`[${ts()}] Heartbeat skipped (excluded window)`);
nextHeartbeatAt = nextAllowedHeartbeatAt(
currentSettings.heartbeat,
@@ -560,7 +533,6 @@ export async function start(args: string[] = []) {
);
return;
}
Promise.all([
resolvePrompt(currentSettings.heartbeat.prompt),
loadHeartbeatPromptTemplate(),
@@ -578,7 +550,6 @@ export async function start(args: string[] = []) {
.then((r) => {
if (r) forwardToTelegram("", r);
});
nextHeartbeatAt = nextAllowedHeartbeatAt(
currentSettings.heartbeat,
currentSettings.timezoneOffsetMinutes,
@@ -602,9 +573,7 @@ export async function start(args: string[] = []) {
console.log(triggerResult.stdout);
if (telegramFlag) forwardToTelegram("", triggerResult);
if (triggerResult.exitCode !== 0) {
console.error(
`[${ts()}] Startup trigger failed (exit ${triggerResult.exitCode}). Daemon will continue running.`
);
console.error(`[${ts()}] Startup trigger failed (exit ${triggerResult.exitCode}). Daemon will continue running.`);
}
} else {
// Bootstrap the session first so system prompt is initial context
@@ -630,48 +599,36 @@ export async function start(args: string[] = []) {
newSettings.heartbeat.prompt !== currentSettings.heartbeat.prompt ||
newSettings.timezoneOffsetMinutes !== currentSettings.timezoneOffsetMinutes ||
newSettings.timezone !== currentSettings.timezone ||
JSON.stringify(newSettings.heartbeat.excludeWindows) !==
JSON.stringify(currentSettings.heartbeat.excludeWindows);
JSON.stringify(newSettings.heartbeat.excludeWindows) !== JSON.stringify(currentSettings.heartbeat.excludeWindows);
// Detect security config changes
const secChanged =
newSettings.security.level !== currentSettings.security.level ||
newSettings.security.allowedTools.join(",") !==
currentSettings.security.allowedTools.join(",") ||
newSettings.security.disallowedTools.join(",") !==
currentSettings.security.disallowedTools.join(",");
newSettings.security.allowedTools.join(",") !== currentSettings.security.allowedTools.join(",") ||
newSettings.security.disallowedTools.join(",") !== currentSettings.security.disallowedTools.join(",");
if (secChanged) {
console.log(`[${ts()}] Security level changed → ${newSettings.security.level}`);
}
if (hbChanged) {
console.log(
`[${ts()}] Config change detected — heartbeat: ${newSettings.heartbeat.enabled ? `every ${newSettings.heartbeat.interval}m` : "disabled"}`
);
console.log(`[${ts()}] Config change detected — heartbeat: ${newSettings.heartbeat.enabled ? `every ${newSettings.heartbeat.interval}m` : "disabled"}`);
currentSettings = newSettings;
scheduleHeartbeat();
} else {
currentSettings = newSettings;
}
if (web) {
currentSettings.web.enabled = true;
currentSettings.web.port = web.port;
}
// Detect job changes
const jobNames = newJobs
.map((j) => `${j.name}:${j.schedule}:${j.prompt}`)
.sort()
.join("|");
const oldJobNames = currentJobs
.map((j) => `${j.name}:${j.schedule}:${j.prompt}`)
.sort()
.join("|");
const jobNames = newJobs.map((j) => `${j.name}:${j.schedule}:${j.prompt}`).sort().join("|");
const oldJobNames = currentJobs.map((j) => `${j.name}:${j.schedule}:${j.prompt}`).sort().join("|");
if (jobNames !== oldJobNames) {
console.log(`[${ts()}] Jobs reloaded: ${newJobs.length} job(s)`);
newJobs.forEach((j) => console.log(` - ${j.name} [${j.schedule}]`));
newJobs.forEach((j) => console.log(` - ${j.name} [${j.schedule}]`));
}
currentJobs = newJobs;
@@ -724,14 +681,16 @@ export async function start(args: string[] = []) {
await clearJobSchedule(job.name);
console.log(`[${ts()}] Cleared schedule for one-time job: ${job.name}`);
} catch (err) {
console.error(
`[${ts()}] Failed to clear schedule for ${job.name}:`,
err
);
console.error(`[${ts()}] Failed to clear schedule for ${job.name}:`, err);
}
});
}
}
updateState();
}, 60_000);
} catch (err) {
console.error("[QwenClaw Start] Fatal error:", err);
process.exit(1);
}
}

View File

@@ -7,20 +7,38 @@ import { send } from "./commands/send";
const args = process.argv.slice(2);
const command = args[0];
if (command === "--stop-all") {
stopAll();
} else if (command === "--stop") {
stop();
} else if (command === "--clear") {
clear();
} else if (command === "start") {
start(args.slice(1));
} else if (command === "status") {
status();
} else if (command === "telegram") {
telegram();
} else if (command === "send") {
send(args.slice(1));
} else {
start();
async function main() {
console.log("[QwenClaw] Index.ts - Command:", command);
if (command === "--stop-all") {
await stopAll();
} else if (command === "--stop") {
await stop();
} else if (command === "--clear") {
await clear();
} else if (command === "start") {
console.log("[QwenClaw] Calling start function...");
await start(args.slice(1));
console.log("[QwenClaw] Start function returned");
} else if (command === "status") {
await status();
} else if (command === "telegram") {
await telegram();
} else if (command === "send") {
await send(args.slice(1));
} else {
console.log("[QwenClaw] No command, starting default...");
await start();
}
}
main().catch((err) => {
console.error("[QwenClaw] Fatal error:", err);
process.exit(1);
});
// Keep process alive
process.on('SIGINT', () => {
console.log('[QwenClaw] Received SIGINT, shutting down...');
process.exit(0);
});

View File

@@ -67,10 +67,14 @@ async function runQwenOnce(
const args = [...baseArgs];
if (model.trim()) args.push("--model", model.trim());
const proc = Bun.spawn(args, {
// Use qwen.cmd on Windows for proper execution
const qwenCommand = process.platform === "win32" ? "qwen.cmd" : "qwen";
const proc = Bun.spawn([qwenCommand, ...args], {
stdout: "pipe",
stderr: "pipe",
env: buildChildEnv(baseEnv, model, api),
shell: true,
});
const [rawStdout, stderr] = await Promise.all([