Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f1879c0
feat(loop): inject a .qwen/loop.md task file at fire time via sentinels
qqqys Jun 26, 2026
7b630fe
fix(cli): remove unused loop mode import
qqqys Jun 26, 2026
f36a5c8
fix(loop): rename loop task-file/tick-resolver modules to kebab-case
qqqys Jun 26, 2026
9b70f16
fix(loop): address re-review suggestions on loop.md injection
qqqys Jun 26, 2026
91759a8
fix(loop): address loop.md re-review — bounded read, cached realpath,…
qqqys Jun 26, 2026
a6da917
fix(loop): address re-review — heading test, absent heading, source l…
qqqys Jun 26, 2026
6c7466d
fix(loop): keep loop.md confinement root internal; harden bounded-rea…
qqqys Jun 26, 2026
0e2128f
Merge branch 'main' into feat/loop-md-injection
wenshao Jun 26, 2026
a278fd0
test(core): cover loop.md project-root realpath cache eviction on tra…
qqqys Jun 26, 2026
89f5ff8
fix(loop): harden loop.md against symlink exfiltration, FIFO hang, an…
qqqys Jun 26, 2026
1ad5362
fix(loop): bracket-access process.env in Session test setFakeHome
qqqys Jun 26, 2026
3e19ba5
fix(loop): fail-secure loop.md default + root confinement, with coverage
qqqys Jun 26, 2026
60edea8
fix(loop): harden loop.md reader/resolver per CI review
qqqys Jun 27, 2026
c0a962a
fix(loop): address CI review on loop.md reader/resolver
qqqys Jun 27, 2026
d0561f0
fix(loop): re-evaluate folder trust per tick; guard durable loop.md i…
qqqys Jun 27, 2026
565398e
fix(loop): honor skipDurableFire in deliverPending missed branch
qqqys Jun 27, 2026
e77e66e
fix(loop): address loop.md injection review (durable guards, logs, QW…
qqqys Jun 27, 2026
d9db495
test(loop): cover all-filtered missed batch and working-dir resolver …
qqqys Jun 27, 2026
f402bae
fix(loop): report real home loop.md path in absent tick; cover resolv…
qqqys Jun 27, 2026
300060c
fix(loop): sanitize loop.md resolve error and guard handle close
qqqys Jun 27, 2026
5acd11a
fix(loop): align skip guards, resolve sentinel pendingRemoval limbo, …
qqqys Jun 28, 2026
eb0f082
fix(loop): never leak an absolute $QWEN_HOME path in the home loop.md…
qqqys Jun 28, 2026
44f45c0
fix(loop): guard UTF-8 truncation boundary and scope resolve-error to…
qqqys Jun 28, 2026
21aebdd
test(loop): lock that one-shot sentinel jobs are not deleted by the h…
qqqys Jun 28, 2026
2f43aeb
fix(loop): guard empty homedir confinement root; interpolate dynamic …
qqqys Jun 28, 2026
75c6c37
fix(loop): reject hard-linked loop.md and keep dynamic loops alive on…
qqqys Jun 28, 2026
9271c43
test(loop): hoist expects out of conditional branches to satisfy vite…
qqqys Jun 28, 2026
54082f0
fix(loop): normalize home label trailing slash; distinguish transient…
qqqys Jun 28, 2026
2116715
fix(loop): only degrade transient fs errors in dynamic mode; log tran…
qqqys Jun 28, 2026
73df2ef
fix(loop): treat EISDIR/ENOTDIR as transient in dynamic mode
qqqys Jun 28, 2026
0198307
test(loop): lock the setSkipDurableFire sentinel-predicate wiring in …
qqqys Jun 28, 2026
4ab4012
fix(loop): keep separator for root QWEN_HOME; distinct heading for tr…
qqqys Jun 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4256,6 +4256,134 @@ describe('Session', () => {
});
});

it('expands a loop.md sentinel into the task block and echoes a clean label', async () => {
const tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'loop-md-session-'),
);
const loopMdPath = path.join(tmpDir, '.qwen', 'loop.md');
await fs.mkdir(path.dirname(loopMdPath), { recursive: true });
await fs.writeFile(loopMdPath, '- finish the migration');
mockConfig.getWorkingDir = vi.fn().mockReturnValue(tmpDir);

const scheduler = {
size: 1,
hasPendingWork: true,
start: vi.fn(
(
callback: (job: { prompt: string; cronExpr?: string }) => void,
) => {
callback({
prompt: '<<loop.md-dynamic>>',
cronExpr: '@wakeup',
});
},
),
stop: vi.fn(),
getExitSummary: vi.fn().mockReturnValue(undefined),
};
mockConfig.isCronEnabled = vi.fn().mockReturnValue(true);
mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(createEmptyStream())
.mockResolvedValueOnce(createEmptyStream());

try {
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
});

// The client sees a stable label, never the raw sentinel.
await vi.waitFor(() => {
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'user_message_chunk',
content: {
type: 'text',
text: `Loop tick — tasks from ${loopMdPath}`,
},
_meta: { source: 'loop' },
},
});
});

// The model receives the expanded full task block, not the sentinel.
let block = '';
await vi.waitFor(() => {
const cronCall = (
mockChat.sendMessageStream as ReturnType<typeof vi.fn>
).mock.calls.find(
(c) =>
Array.isArray(c[1]?.message) &&
c[1].message.some((p: { text?: string }) =>
p.text?.includes('finish the migration'),
),
);
expect(cronCall).toBeDefined();
block = (cronCall![1].message as Array<{ text?: string }>)
.map((p) => p.text ?? '')
.join('');
});
expect(block).toContain('# /loop tick — tasks from');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The heading format was changed from "tasks from" to "loop.md tasks from" in the production code (tickHeading()) and the core unit tests were updated, but this Session integration test assertion was not. The CLI test suite fails with:

AssertionError: expected "# /loop tick — loop.md tasks from project loop.md ..." to contain "# /loop tick — tasks from"

This is likely the cause of the CI Test (ubuntu-latest, Node 22.x) failure.

Suggested change
expect(block).toContain('# /loop tick — tasks from');
expect(block).toContain('# /loop tick — loop.md tasks from');

— qwen3.7-max via Qwen Code /review

expect(block).toContain('- finish the migration');
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});

it('leaves a non-sentinel cron prompt untouched (no loop.md expansion)', async () => {
const scheduler = {
size: 1,
hasPendingWork: true,
start: vi.fn(
(
callback: (job: { prompt: string; cronExpr?: string }) => void,
) => {
callback({
prompt: 'do the normal cron thing',
cronExpr: '0 * * * *',
});
},
),
stop: vi.fn(),
getExitSummary: vi.fn().mockReturnValue(undefined),
};
mockConfig.isCronEnabled = vi.fn().mockReturnValue(true);
mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(createEmptyStream())
.mockResolvedValueOnce(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
});

await vi.waitFor(() => {
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'do the normal cron thing' },
_meta: { source: 'cron' },
},
});
});

const sentToModel = () =>
(mockChat.sendMessageStream as ReturnType<typeof vi.fn>).mock.calls
.flatMap((c) => (Array.isArray(c[1]?.message) ? c[1].message : []))
.map((p: { text?: string }) => p.text ?? '')
.join('');
await vi.waitFor(() => {
expect(sentToModel()).toContain('do the normal cron thing');
});
expect(sentToModel()).not.toContain('# /loop tick');
});

it('stops cron-fired ACP prompt before sending when the session token limit is exceeded', async () => {
let cronCallback: ((job: { prompt: string }) => void) | undefined;
const scheduler = {
Expand Down
67 changes: 65 additions & 2 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import * as os from 'node:os';
import type {
Content,
FunctionCall,
Expand Down Expand Up @@ -33,6 +34,8 @@ import {
AuthType,
ApprovalMode,
CompressionStatus,
detectLoopSentinel,
LoopTickResolver,
convertToFunctionResponse,
createDuplicateProviderToolCallResponse,
createDebugLogger,
Expand Down Expand Up @@ -664,6 +667,13 @@ export class Session implements SessionContext {
private cronQueue: CronQueueItem[] = [];
private cronProcessing = false;
private cronAbortController: AbortController | null = null;
// Resolves the `<<loop.md>>` / `<<loop.md-dynamic>>` sentinels at fire time.
// Lazily created on the first loop tick; its content cache is reset on
// compaction (see #sendMessageStreamWithAutoCompression) and it is rebuilt if
// the working dir changes (e.g. /cd) so it always reads the current project's
// loop.md.
private loopTickResolver: LoopTickResolver | null = null;
private loopTickResolverRoot: string | null = null;
private cronCompletion: Promise<void> | null = null;
private cronDisabledByTokenLimit = false;
private lastPromptTokenCount = 0;
Expand Down Expand Up @@ -1926,6 +1936,10 @@ export class Session implements SessionContext {
compressionInfo = compressed;
this.#recordCompressionTokenCount(compressed);
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
// Context was just compacted; a loop.md tick must re-deliver the full

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This cache reset only covers automatic sends. ACP /compress and /compress-fast call geminiClient.tryCompressChat / tryCompressChatFast directly from the slash-command handlers and return through #processSlashCommandResult, so this Session's loopTickResolver cache survives a successful manual compression. The next unchanged loop tick can then send only a short reminder even though the full block was summarized away. Centralize a compression-completed hook, or reset loopTickResolver after successful ACP compression commands too.

— GPT-5 via Qwen Code /review

// task block (a short reminder refers back to a message that is no
// longer in context).
this.loopTickResolver?.resetCache();
const reasonClause =
compressed.triggerReason === 'image_overflow'
? `accumulated enough tool screenshots to trigger compaction for ${this.config.getModel()}`
Expand Down Expand Up @@ -2427,6 +2441,20 @@ export class Session implements SessionContext {
}
}

#getLoopTickResolver(): LoopTickResolver {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No Session-level test exercises the working-dir-change rebuild path here. All existing Session loop tests use a single fixed getWorkingDir() mock, so the this.loopTickResolverRoot !== root trigger is never exercised through the integration layer.

A regression that drops the loopTickResolverRoot tracking field would silently keep reading the old project's .qwen/loop.md after a /cd, feeding stale tasks from a previous workspace.

Test needed: Fire two loop ticks with getWorkingDir() returning different dirs between them (each with a distinct loop.md). Assert the second tick delivers the NEW project's full block.

— qwen3.7-max via Qwen Code /review

const root = this.config.getWorkingDir();
// Rebuild if the working dir changed (e.g. /cd) so loop.md resolves against
// the current project; a fresh resolver also correctly re-delivers full.
if (!this.loopTickResolver || this.loopTickResolverRoot !== root) {
this.loopTickResolver = new LoopTickResolver({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] LoopTickResolver is constructed from the current working directory unconditionally, so even when folder trust is enabled and this folder is untrusted, readLoopTaskFile still consumes project-controlled .qwen/loop.md. Other project-controlled surfaces such as workspace hooks, saved workflows, and MCP discovery are skipped in untrusted folders; this path should apply the same boundary. Please skip the project candidate when !config.isTrustedFolder() and allow only the home candidate, or pass an explicit trust flag into the reader.

— GPT-5 via Qwen Code /review

projectRoot: root,
homeDir: os.homedir(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The home/global loop.md candidate is resolved from raw os.homedir() (here, and as the default in readLoopTaskFile), so it always reads ~/.qwen/loop.md at the real OS home and ignores the QWEN_HOME env var. Every other ~/.qwen consumer in the codebase resolves through Storage.getGlobalQwenDir() (which honors QWEN_HOME) — including this feature's own durable cron persistence (cronTasksFile.ts → Storage.getGlobalTempDir()) and the closest analog, global rules (rulesDiscovery.ts: path.join(Storage.getGlobalQwenDir(), 'rules')). This is the only new ~/.qwen/<file> reader that bypasses that convention.

With QWEN_HOME set this produces a split-brain: a durable <<loop.md>> cron job persists under $QWEN_HOME, but the task list it re-reads is looked up at the real ~/.qwen/loop.md, so the user's $QWEN_HOME/loop.md is silently ignored (resolver reports "loop.md not present"). The symlink-confinement boundary (isWithin(realHome, …)) is likewise anchored to the real home instead of the active qwen dir. The default (no QWEN_HOME) path is unaffected, which is likely why this wasn't caught earlier.

Suggested fix: resolve the home candidate's base from Storage.getGlobalQwenDir() rather than raw os.homedir(). Note that getGlobalQwenDir() already is the .qwen dir (and for QWEN_HOME=/foo it is /foo), so the home candidate should become path.join(globalQwenDir, 'loop.md') instead of re-appending .qwen inside the reader. When picking the new confinement root, keep the existing tests' in-$HOME symlink-follow behavior in mind.

— claude-opus-4-8[1m] via Qwen Code /qreview

});
this.loopTickResolverRoot = root;
}
return this.loopTickResolver;
}

/**
* Executes a single cron-fired prompt: echoes it as a user message with
* `_meta.source='cron'`, streams the model response, and handles tool calls.
Expand Down Expand Up @@ -2460,10 +2488,38 @@ export class Session implements SessionContext {
async () => {
let turnCount = 0;
try {
// A `<<loop.md>>` / `<<loop.md-dynamic>>` sentinel is expanded at
// fire time into the loop.md task block — full on the first or a
// changed fire, a short reminder when unchanged. Non-sentinel
// prompts pass through untouched.
const loopMode = detectLoopSentinel(prompt);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This expansion happens after the scheduled prompt has already been approved/classified as the harmless literal sentinel. Because loop.md is reread at fire time and can change after scheduling, a project/home file edit can replace an approved sentinel with new model instructions that execute with cron/loop tool access without going through the same future-prompt scrutiny. This is separate from prompt framing: even delimiters would not re-approve changed file contents. Run the resolved full block through the same approval/classifier path whenever it is first delivered or changes, or require explicit user confirmation before sending changed loop.md contents.

— GPT-5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] loopMode is derived only from the sentinel string, but the authoritative schedule type is already in scope as item.source ('loop' for a @wakeup/LoopWakeup job, 'cron' for a recurring CronCreate — set at line 2413). The re-arm guidance the model receives is selected solely by the sentinel, so the two can silently disagree:

  • A @wakeup job (item.source === 'loop', one-shot) whose prompt is <<loop.md>> → mode = 'cron' → the reminder says "the recurring cron fires the next tick automatically — do not call LoopWakeup". Nothing re-fires, so the self-paced loop dies silently after one tick.
  • A recurring cron (item.source === 'cron') whose prompt is <<loop.md-dynamic>> → mode = 'dynamic' → the reminder tells the model to re-arm LoopWakeup, which then double-fires on top of the auto-firing cron.

The re-arm instruction is about the firing mechanism (does this job auto-repeat?), which item.source already knows — not the model's sentinel choice. SKILL.md pairs them correctly, but nothing enforces it and a mis-pairing fails with no diagnostic. (This is distinct from the already-noted literal-collision concern on detectLoopSentinel.) Derive the mode from item.source (or assert it agrees with the sentinel):

Suggested change
const loopMode = detectLoopSentinel(prompt);
const loopMode =
detectLoopSentinel(prompt) === null
? null
: item.source === 'loop'
? 'dynamic'
: 'cron';

— claude-opus-4-8 via Qwen Code /qreview

const loopTick = loopMode
? await this.#getLoopTickResolver().resolve(loopMode)
: null;
const modelText = loopTick ? loopTick.modelText : prompt;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] modelText is built before #sendMessageStreamWithAutoCompression can compact. On an unchanged tick, resolve() returns only the short reminder; if compression happens in the send path, resetCache() runs but the already-built short reminder is still sent into the new compressed context that no longer contains the full task block. The next tick will recover, but this tick silently loses the task state. Run compression before resolving the sentinel, or have the send path report COMPRESSED so the cron path can reset and re-resolve before sending.

— GPT-5 via Qwen Code /review

if (loopTick) {
debugLogger.debug(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The tick delivery debug log cannot distinguish a transient-error tick from a genuinely-absent file. Both buildTransientErrorTick and the genuine-absent path produce full: false and sourceLabel: undefined, so the log prints delivery=absent source=none for both cases. The transientError flag on the result is never consulted in the log expression. An oncall engineer tailing debug logs would see delivery=absent and conclude "the file doesn't exist," when the real situation is "the file exists but can't be read."

Add a transient field so a single log line is self-explanatory:

Suggested change
debugLogger.debug(
debugLogger.debug(
`loop tick: mode=${loopMode} delivery=${
loopTick.full
? 'full'
: loopTick.sourceLabel
? 'reminder'
: 'absent'
} source=${loopTick.sourceLabel ?? 'none'} transient=${loopTick.transientError ?? false}`,
);

— qwen3.7-max via Qwen Code /review

`loop tick: mode=${loopMode} delivery=${
loopTick.full
? 'full'
: loopTick.sourcePath
? 'reminder'
: 'absent'
} path=${loopTick.sourcePath ?? 'none'}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Debug log leaks the absolute sourcePath (e.g., /Users/alice/.qwen/loop.md) every tick, while the model-facing text correctly uses a relative label ("project loop.md") to avoid leaking the OS path to the API provider. Debug logs may be captured to log files, forwarded to telemetry, or included in crash reports — a separate leak channel from the UI.

Suggested change
} path=${loopTick.sourcePath ?? 'none'}`,
} source=${loopTick.source ?? 'none'}`,

(This requires LoopTickResult to expose the source enum alongside sourcePath.)

— qwen3.7-max via Qwen Code /review

);
}
// For a loop tick echo a stable label, never the bare sentinel or
// the full task dump; otherwise echo the prompt verbatim.
const echoText = loopTick
? loopTick.sourcePath
? `Loop tick — tasks from ${loopTick.sourcePath}`
: 'Loop tick — loop.md not present'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new absent-file branch (loopTick && !loopTick.sourcePath) is not covered by the Session integration tests. The present-file sentinel test and non-sentinel test would still pass if this label or absent no-op model text regressed. Add a Session test with no project/home loop.md that schedules <<loop.md-dynamic>>, then assert the client sees Loop tick — loop.md not present and the model receives the absent loop.md heading/body.

— GPT-5 via Qwen Code /review

: prompt;

// Echo the cron prompt as a user message so the client sees it
await this.sendUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: prompt },
content: { type: 'text', text: echoText },
_meta: { source: item.source },
});

Expand All @@ -2472,7 +2528,7 @@ export class Session implements SessionContext {
const cronReminders = await this.#buildInitialSystemReminders();
let nextMessage: Content | null = {
role: 'user',
parts: [...cronReminders, { text: prompt }],
parts: [...cronReminders, { text: modelText }],
};

while (nextMessage !== null) {
Expand Down Expand Up @@ -2501,6 +2557,13 @@ export class Session implements SessionContext {
return;
}
const responseStream = sendResult.responseStream;
if (loopTick && turnCount === 1) {
// The block reached the model (the send started); commit it so
// the next tick can detect "unchanged". Deferring the commit
// to here keeps an abort before delivery from poisoning the
// cache into a dangling short reminder.
this.loopTickResolver?.markDelivered();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] markDelivered() + resetCache() ordering trap: when auto-compression fires during the first turn of a loop tick (inside #sendMessageStreamWithAutoCompression at line 1943), resetCache() clears both #pendingContent and #lastContent. Then this markDelivered() call finds #pendingContent null and commits nothing — even though the full task block successfully reached the model. Every subsequent tick re-delivers the full block (instead of the cheap one-line reminder) until the file content actually changes, silently burning tokens on cron-mode loops.

Consider having resetCache() promote #pendingContent to #lastContent (rather than discarding both), so that post-compression ticks correctly see "unchanged" content. Alternatively, add a comment at both call sites warning that the ordering is load-bearing.

— qwen3.7-max via Qwen Code /review

}
nextMessage = null;

for await (const resp of responseStream) {
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
uiTelemetryService,
parseAndFormatApiError,
createDebugLogger,
detectLoopSentinel,
SendMessageType,
restoreWorktreeContext,
TeamEventType,
Expand Down Expand Up @@ -1521,6 +1522,15 @@ export async function runNonInteractive(
};

scheduler.start((job: { prompt: string; cronExpr?: string }) => {
// loop.md sentinel expansion is interactive-only for now; in a
// headless run a bare `<<loop.md>>` sentinel would reach the
// model as its prompt with no task content, so skip the tick
// (no-op) instead of enqueuing it. Full headless loop.md support
// is a follow-up.
if (detectLoopSentinel(job.prompt)) {
checkCronDone();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] For headless runs, skipping a loop sentinel calls checkCronDone() but leaves session-only recurring cron jobs in scheduler.sessionSize. A fixed-interval loop.md job uses CronCreate with recurring: true and defaults to session-only unless persistence is requested, so this branch can leave qwen --print or CI idle forever, repeatedly skipping the same sentinel with no model turn. Treat unsupported headless sentinels as terminal by stopping/deleting the session-only job, or resolve/expand them in headless too.

— GPT-5 via Qwen Code /review

return;
}
const label = job.prompt.slice(0, 40);
localQueue.push({
displayText: `${job.cronExpr === '@wakeup' ? 'Loop' : 'Cron'}: ${label}`,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ export {
export * from './services/chatRecordingService.js';
export * from './services/cronScheduler.js';
export type { DurableCronTask } from './services/cronTasksFile.js';
export * from './skills/bundled/loop/loop-task-file.js';
export * from './skills/bundled/loop/loop-tick-resolver.js';
export * from './services/fileDiscoveryService.js';
export * from './services/fileHistoryService.js';
export * from './services/fileReadCache.js';
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/skills/bundled/loop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,13 @@ If the interval does not cleanly divide its unit (for example `7m` gives uneven
- If it is a slash command, invoke it via the Skill tool.
- Otherwise, act on it directly.

## loop.md task-file mode

Use this when the user wants the loop to work a task list kept in a file (they say "work through my loop.md", "loop over the tasks in .qwen/loop.md", or point at such a file). Tasks live in `.qwen/loop.md` (project) or `~/.qwen/loop.md` (home; project wins). Instead of a natural-language prompt, set the loop's `prompt` to a sentinel so each fire re-reads the file:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This new sentinel mode is only resolved in ACP. The normal interactive TUI scheduler still enqueues modelText: job.prompt in packages/cli/src/ui/hooks/useGeminiStream.ts, so a /loop scheduled through the main CLI sends <<loop.md>> / <<loop.md-dynamic>> to the model instead of the task file. That makes the advertised loop.md mode fail for the primary interactive path. Wire detectLoopSentinel / LoopTickResolver into the TUI cron consumer (including cache reset on TUI compaction), or keep the skill from emitting sentinels until every scheduler consumer supports them.

— GPT-5 via Qwen Code /review


- Self-paced (no interval) → LoopWakeup `prompt`: `<<loop.md-dynamic>>`
- Fixed interval → CronCreate `prompt`: `<<loop.md>>` (with `recurring: true`, and `durable: true` if persistence is implied)

At each fire you receive either the full task list (first delivery, after the file changes, or after a compaction) or a short reminder to keep working the list established earlier. Work the tasks; in self-paced mode re-arm LoopWakeup with `<<loop.md-dynamic>>` only when continued follow-up is useful (same "don't re-arm if complete/blocked" rules as the prompt-only path). If `.qwen/loop.md` is absent at fire time, treat the tick as a no-op. Confirm to the user in plain language ("looping over your `.qwen/loop.md` task list…"), not the raw sentinel.

## Input
9 changes: 9 additions & 0 deletions packages/core/src/skills/bundled/loop/SKILL.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,13 @@ describe('bundled loop skill', () => {
expect(body).toContain('**`clear`** — call CronList');
expect(body).toContain('call CronDelete for every job returned');
});

it('documents loop.md task-file mode and the two sentinels', () => {
const { body } = loadLoopSkill();

expect(body).toContain('## loop.md task-file mode');
expect(body).toContain('.qwen/loop.md');
expect(body).toContain('`<<loop.md-dynamic>>`');
expect(body).toContain('`<<loop.md>>`');
});
});
Loading
Loading