/** * @license * Copyright 2026 Qwen * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import { tildeifyPath } from '../../../utils/paths.js'; import { LOOP_TASK_FILE_MAX_BYTES, readLoopTaskFile, type LoopTaskFileSource, } from './loop-task-file.js'; import { AUTONOMOUS_PREAMBLE, AUTONOMOUS_PREAMBLE_MARKER, CRON_REARM, autonomousTickText, keepAliveRearm, type LoopMode, } from './autonomous-loop.js'; export { AUTONOMOUS_SENTINEL_CRON, AUTONOMOUS_SENTINEL_DYNAMIC, AutonomousLoopTickResolver, detectAutonomousSentinel, } from './autonomous-loop.js'; export type { LoopMode } from './autonomous-loop.js'; /** * Fire-time resolver for `.qwen/loop.md`-driven loops. * * A `/loop` whose scheduled prompt is one of these sentinels re-reads loop.md * on every fire and gets either the FULL task block (first delivery, or whenever * the file changed) or a one-line SHORT reminder (unchanged) — so the task list * is paid for once into the cached message-prefix and later ticks stay cheap. * * Divergence from the upstream design this mirrors: the `lastContent` cache is * held per Session instance (not a module singleton) so it scopes to one * conversation and resets cleanly with that conversation's context (compaction). * Change-detection is full content equality, not mtime/hash, so edit and * delete→recreate both re-expand for free. */ export const LOOP_SENTINEL_CRON = '<>'; export const LOOP_SENTINEL_DYNAMIC = '<>'; export interface LoopTickResolverDeps { /** Pass `config.getWorkingDir()` — loop.md is resolved against the cwd. */ projectRoot: string; /** Home-candidate confinement root: `$QWEN_HOME` when set, else `$HOME`. */ homeDir: string; /** * QWEN_HOME-aware global dir holding the home `loop.md` (`Storage.getGlobalQwenDir()`). * Omitted → defaults to `/.qwen` inside readLoopTaskFile. */ homeQwenDir?: string; /** * Pass `() => config.isTrustedFolder()`. Re-evaluated on every `resolve()`, * never captured once: `isTrustedFolder()` is not process-stable in IDE * sessions (a workspace-trust update can flip it), and a trusted→untrusted * flip must immediately stop reading the repo-controlled project * `.qwen/loop.md` (the user-owned `~/.qwen/loop.md` still is read). */ allowProjectFile: () => boolean; } export interface LoopTickResult { /** Text to deliver to the model in place of the sentinel prompt. */ modelText: string; /** True when the full task block was delivered (vs a short reminder). */ full: boolean; /** Non-absolute label for the matched candidate (e.g. "project loop.md"), * when present — safe for logs/UI that must not leak the absolute path, and * doubles as the "a loop.md was found" flag for callers. */ sourceLabel?: string; /** True ONLY for buildTransientErrorTick: a loop.md exists but could not be * read THIS tick (a transient EACCES/EIO or editor/AV lock), as distinct from * the genuinely-absent no-op (where this stays false). Lets the caller's echo * say "temporarily unavailable" instead of "not present". Carries no errno or * path — those stay in the modelText note and LOCAL debug logs only. */ transientError?: boolean; /** True when this tick is an autonomous-mode tick (a `<>` * fire, or a loop.md sentinel whose file is gone and has converged on the * autonomous preamble). Lets the caller's echo label it distinctly. */ autonomous?: boolean; } const TRUNCATION_WARNING = `> WARNING: loop.md was truncated to ${LOOP_TASK_FILE_MAX_BYTES} bytes. Keep the task list concise.`; const INTRO = 'The user configured a loop-tasks file. Work through the tasks defined below; these are the instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).'; // Mode-specific pacing guidance. Appended to BOTH the full block and the short // reminder — the no-op/re-arm instruction applies on every tick. const PACING_SUFFIX: Record = { cron: CRON_REARM, dynamic: `You scheduled this tick via LoopWakeup (not a recurring cron). To keep the loop alive, call LoopWakeup again at the end of this turn with prompt set to the literal sentinel \`${LOOP_SENTINEL_DYNAMIC}\` — otherwise the loop ends after this tick.`, }; // Preamble for the UNCHANGED-tick reminder, which points back to the full block // delivered on an earlier fire. NOT used on the first/changed full delivery, // where the block is present in THIS message — there is no "earlier" to refer // back to, so claiming the contents were established earlier would contradict // the INTRO that sits right above them. const SHORT_REMINDER_PREAMBLE = 'Work the tasks from the loop.md contents established earlier in this conversation. If you cannot find them, treat this as a no-op tick.'; /** * The single H1 for every tick variant (full block, short reminder, absent), so * they share one heading style and the dynamic-pacing suffix lives in one place. * `sourceLabel` (set only on a full-block delivery) is a relative label like * "project loop.md", never the absolute path — so the resolved file location * isn't leaked to the model/API provider. */ function tickHeading( mode: LoopMode, opts: { sourceLabel?: string; absent?: boolean; unavailable?: boolean } = {}, ): string { // `unavailable` (transient read failure) is distinct from `absent`: the file // exists but couldn't be read THIS tick, so the heading must not claim it's gone. const subject = opts.unavailable ? 'loop.md unavailable' : opts.absent ? 'loop.md absent' : opts.sourceLabel ? `loop.md tasks from ${opts.sourceLabel}` : 'loop.md tasks'; const base = `# /loop tick — ${subject}`; return mode === 'dynamic' ? `${base} (dynamic pacing)` : base; } /** Model-safe relative label per source — exhaustive, so a new loop.md * candidate added to readLoopTaskFile won't compile until it gets a label * (rather than silently mislabelling it). */ const SOURCE_LABELS: Record = { project: 'project loop.md', home: 'home loop.md', }; // Per-mode tail for a TRANSIENT-failure no-op tick (buildTransientErrorTick): // "treat this as a no-op" + the mode's re-arm. A genuinely-absent loop.md does // NOT use this — it converges on absentAutonomousTickText (run the autonomous // check, not a no-op). const ABSENT_TAIL: Record = { cron: 'Treat this as a no-op tick; the recurring cron fires the next tick automatically.', dynamic: `Treat this as a no-op tick. To pick it up if it is recreated, call LoopWakeup again with prompt set to the literal sentinel \`${LOOP_SENTINEL_DYNAMIC}\` — otherwise the loop ends after this tick.`, }; /** Detect whether a scheduled prompt is a loop.md sentinel, and which mode. */ export function detectLoopSentinel(prompt: string): LoopMode | null { const trimmed = prompt.trim(); if (trimmed === LOOP_SENTINEL_DYNAMIC) { return 'dynamic'; } if (trimmed === LOOP_SENTINEL_CRON) { return 'cron'; } return null; } // Shared self-paced re-arm instruction; the loop.md and autonomous dynamic ticks // differ only in the sentinel the model re-arms with, so build both from one // template to keep them in lockstep. // `keepAliveRearm` itself lives in autonomous-loop.ts so the interactive TUI // path can expand autonomous sentinels without importing loop.md file readers. // Re-arm guidance for an absent-loop.md tick that has converged on autonomous // mode: re-arm the LOOP.MD sentinel (not the autonomous one) so a recreated file // is picked up on the next fire. const ABSENT_AUTONOMOUS_REARM: Record = { cron: PACING_SUFFIX.cron, dynamic: keepAliveRearm( LOOP_SENTINEL_DYNAMIC, 'To pick up loop.md if it is recreated', ), }; /** The tick text for an absent loop.md that converges on autonomous mode — like * a pure autonomous tick but headed "loop.md absent" and re-arming the loop.md * sentinel so a recreated file is picked up. It says "run the autonomous check", * NOT an unconditional no-op: the no-op wording would contradict the preamble * prepended on the first fire (and the dedup tick that follows it). */ function absentAutonomousTickText(mode: LoopMode, locations: string): string { return `${tickHeading(mode, { absent: true })}\nloop.md is not currently present at ${locations}. Run the autonomous check using the loop instructions established earlier in this conversation. If you cannot find them, treat this as a no-op tick and stop immediately. ${ABSENT_AUTONOMOUS_REARM[mode]}`; } /** Trim a truncated body back to its last full line before the warning tail. */ function cutToLastNewline(content: string): string { const cut = content.lastIndexOf('\n'); // `> 0`, not `>= 0`: when the only newline is at index 0 (or there is none), // there is no complete line to keep, so cutting would empty the body and leave // the INTRO promising tasks that aren't there. Keep the (truncated) content // instead — only a genuine trailing partial line (newline at index > 0) is // dropped so the warning never glues onto a half-line. return cut > 0 ? content.slice(0, cut) : content; } export class LoopTickResolver { // What the model has actually received. Drives full-vs-reminder detection. #lastContent: string | typeof AUTONOMOUS_PREAMBLE_MARKER | null = null; // The most recent resolve()'s content, committed to #lastContent only once // the caller confirms it reached the model (markDelivered) — so a tick that // is aborted between resolve() and delivery can't poison the cache into // sending a dangling short reminder next time. #pendingContent: string | typeof AUTONOMOUS_PREAMBLE_MARKER | null = null; // Instance-scoped fs.realpath cache for the confinement boundaries, handed to // readLoopTaskFile. Tying it to the resolver (a fresh Map per /cd rebuild, // cleared by resetCache) keeps the per-tick perf win while staying // invalidatable — a module-global cache would pin a stale boundary in a // long-lived process after a /cd or symlink re-point. readonly #realDirCache = new Map>(); constructor(private readonly deps: LoopTickResolverDeps) {} /** Forget the delivered content so the next fire re-delivers the full block * — called when the conversation is compacted (fresh context). */ resetCache(): void { this.#lastContent = null; this.#pendingContent = null; // A reset may follow a /cd or symlink change, so drop the cached boundary // realpaths too and re-resolve them on the next tick. this.#realDirCache.clear(); } /** Commit the last resolve()'s content once it has reached the model. */ markDelivered(): void { if (this.#pendingContent !== null) { this.#lastContent = this.#pendingContent; } } /** Expand an autonomous tick: the full preamble + tick text on the first * delivery, then only the short tick text once the preamble was committed * (markDelivered sets #lastContent to the shared marker). Shared by a pure * autonomous fire and the absent-loop.md convergence in resolve(), so the * preamble is delivered once across both. */ #autonomousTick(tickText: string): LoopTickResult { // Set the marker in BOTH branches (like resolve() sets #pendingContent above // its short/full split): the short branch must also refresh it, or a stale // value left by a previously-aborted full tick would be committed by the next // markDelivered() and poison a later fire into a dangling short reminder. this.#pendingContent = AUTONOMOUS_PREAMBLE_MARKER; if (this.#lastContent === AUTONOMOUS_PREAMBLE_MARKER) { return { modelText: tickText, full: false, autonomous: true }; } return { modelText: `${AUTONOMOUS_PREAMBLE}\n${tickText}`, full: true, autonomous: true, }; } /** Resolve an autonomous-loop sentinel fire (a bare `/loop`, no loop.md). * Synchronous — the preamble is static; only the dedup state is consulted. */ resolveAutonomous(mode: LoopMode): LoopTickResult { return this.#autonomousTick(autonomousTickText(mode)); } /** MODEL-FACING label for the home loop.md location. Mirrors * readLoopTaskFile's home candidate (`/loop.md`) so the absent * reminder — and the caller's sanitized resolve-error — names the location * actually checked (QWEN_HOME-aware), but must NEVER surface a raw absolute * path: it flows into model/API text, leaking the host's filesystem layout. * - under $HOME → tilde-abbreviated `~/.qwen/loop.md`; * - relocated via $QWEN_HOME → the literal `$QWEN_HOME/loop.md`, not the * resolved dir (`tildeifyPath` only abbreviates $HOME, so it's a no-op for * a $QWEN_HOME outside $HOME and would otherwise pass the path through); * - any other out-of-$HOME dir → a generic placeholder, never the path. * The real absolute path stays in LOCAL debug logs only. */ homeLoopLabel(): string { const homeQwenDir = this.deps.homeQwenDir ?? path.join(this.deps.homeDir, '.qwen'); const homeLoopPath = path.join(homeQwenDir, 'loop.md'); const tildeified = tildeifyPath(homeLoopPath); if (tildeified !== homeLoopPath) { return tildeified.replace(/\\/g, '/'); } // Outside $HOME: tildeifyPath was a no-op. When $QWEN_HOME relocated the // global dir (homeQwenDir is its resolved value), report the literal env-var // name — never the absolute path. The home candidate is always // `/loop.md`, so swap the whole resolved dir for `$QWEN_HOME` and // re-attach the user-facing POSIX slash + basename directly. Deriving the tail from the // resolved path's length instead mishandles edge dirs: a trailing slash // (`$QWEN_HOME=/x/.qwen/`) over-counts the separator, and a filesystem-root // homeQwenDir (`$QWEN_HOME=/` → homeLoopPath `/loop.md`, dirname `/`) drops the // leading separator — both garbling the tail into `$QWEN_HOMEloop.md`. if (process.env['QWEN_HOME']) { return '$QWEN_HOME/loop.md'; } return 'the configured global loop.md'; } /** The checked-candidate "where" string shared by the absent reminder and the * caller's sanitized resolve-error. Names the project candidate ONLY when it * was actually read (`projectChecked` — a trusted folder), so neither path can * claim `.qwen/loop.md (project)` for an untrusted folder where the project * file is skipped. The home label is the QWEN_HOME-aware, never-absolute * homeLoopLabel(). Single source of truth so the two messages can't drift. */ absentLocations(projectChecked: boolean): string { const homeLabel = this.homeLoopLabel(); return projectChecked ? `.qwen/loop.md (project) or ${homeLabel} (home)` : `${homeLabel} (home)`; } /** A model-facing no-op tick for a loop.md that is unreadable THIS tick (a * transient read failure — see buildTransientErrorTick). Clears the * change-detection caches so a later successful tick re-delivers the FULL block * instead of a dangling short reminder. A genuinely-absent loop.md no longer * routes here — it converges on the autonomous preamble in resolve(). */ #noOpTick(modelText: string, transientError = false): LoopTickResult { this.#pendingContent = null; if (this.#lastContent !== AUTONOMOUS_PREAMBLE_MARKER) { this.#lastContent = null; } return { modelText, full: false, transientError }; } /** * No-op tick for a transient, non-whitelisted read error (EACCES/EIO, or a * Windows editor/AV briefly locking loop.md). Mirrors the absent tick — same * heading + the mode's re-arm tail (ABSENT_TAIL) — so a `dynamic` loop still * re-arms LoopWakeup and survives the hiccup instead of dying silently: its * firing wakeup was already consumed by the scheduler, and only the * end-of-turn re-arm keeps it alive, so a thrown turn ends the loop forever. * `cron` callers don't use this (they re-fire on their own next interval). * `projectChecked` is the trust captured for THIS tick (so the named candidate * set matches what was probed); `code` is the errno only — never an absolute * path — for a brief model-facing note. */ buildTransientErrorTick( mode: LoopMode, projectChecked: boolean, code: string, ): LoopTickResult { return this.#noOpTick( // `unavailable`, not `absent`: the file exists but was unreadable this tick, // so the heading mirrors the body instead of contradicting it. `${tickHeading(mode, { unavailable: true })}\nloop.md at ${this.absentLocations( projectChecked, )} could not be read this tick (${code}). ${ABSENT_TAIL[mode]}`, // Flag the tick as a transient read failure (file exists, unreadable this // tick) so the caller's echo distinguishes it from a genuinely-absent file. true, ); } /** * @param allowProjectFileOverride Trust captured once by the caller for this * tick (see LoopTickResolverDeps.allowProjectFile). Threaded in — rather than * re-reading the getter here — so the caller's error path can name the SAME * candidate set that was probed even if `isTrustedFolder()` flips mid-tick. * Omitted by direct callers, who fall back to the per-tick getter. */ async resolve( mode: LoopMode, allowProjectFileOverride?: boolean, ): Promise { // Re-read trust per tick (see LoopTickResolverDeps.allowProjectFile): a // resolver built while trusted must skip the project file once trust flips. // Captured so the absent reminder reflects what was ACTUALLY checked. const allowProjectFile = allowProjectFileOverride ?? this.deps.allowProjectFile(); const result = await readLoopTaskFile({ projectRoot: this.deps.projectRoot, homeDir: this.deps.homeDir, homeQwenDir: this.deps.homeQwenDir, allowProjectFile, realDirCache: this.#realDirCache, }); if (result.status === 'missing') { // Absent loop.md converges on autonomous mode: prepend the autonomous // preamble (once, deduped via the shared marker) so a file-less loop keeps // working autonomously instead of no-op'ing forever. The tick text says // "run the autonomous check" (NOT an unconditional no-op, which would // contradict the prepended preamble) and keeps the loop.md-absent heading + // a loop.md-sentinel re-arm so a recreated file is still picked up (its // content can never equal the marker, so it re-delivers full). return this.#autonomousTick( absentAutonomousTickText(mode, this.absentLocations(allowProjectFile)), ); } const content = result.truncated ? `${cutToLastNewline(result.content)}\n${TRUNCATION_WARNING}` : result.content; this.#pendingContent = content; // Label by which candidate matched, never result.path (the absolute path), // which would leak the OS username / dir layout to the API provider and to // debug logs. The label alone is enough for the caller's UI and presence // check, so the absolute path is not surfaced on the result at all. const sourceLabel = SOURCE_LABELS[result.source]; if (this.#lastContent === content) { return { modelText: `${tickHeading(mode)}\n${SHORT_REMINDER_PREAMBLE} ${PACING_SUFFIX[mode]}`, full: false, sourceLabel, }; } // First/changed full delivery: INTRO + the block itself, then only the // pacing suffix — no "established earlier" preamble, which would contradict // the block sitting right here in this same message. return { modelText: `${tickHeading(mode, { sourceLabel })}\n${INTRO}\n${content}\n${PACING_SUFFIX[mode]}`, full: true, sourceLabel, }; } }