Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
80dc6d5
feat(core): event monitor tool with throttled stdout streaming (Phase C)
doudouOUC Apr 28, 2026
4755dc0
fix(cli): hold-back loop waits for monitors + emit task_started for SDK
doudouOUC Apr 28, 2026
7637731
fix(core): Windows process kill + pipeline sleep false-positive
doudouOUC Apr 28, 2026
a929fd0
merge: resolve conflicts with main (task_stop/send_message + hasUnfin…
doudouOUC Apr 28, 2026
614b681
fix(test): resolve TS errors in monitor.test.ts mock types
doudouOUC Apr 28, 2026
6e4b930
merge: resolve conflicts with main (backgroundShellRegistry from #3642)
doudouOUC Apr 28, 2026
2ba425f
fix(core): remove false notification promise + add early-abort guard
doudouOUC Apr 28, 2026
08104b7
fix(test): add getMonitorRegistry mock to useGeminiStream tests
doudouOUC Apr 28, 2026
2783d3e
fix(test): add getMonitorRegistry mock to nonInteractiveCli tests
doudouOUC Apr 28, 2026
273b036
fix(core): address PR review — CORE_TOOLS, directory param, test fix
doudouOUC Apr 28, 2026
8d39c99
fix(core): address MonitorTool review #4186888042
doudouOUC Apr 28, 2026
85ea4f0
fix(core): scope monitor always-allow permissions
doudouOUC Apr 28, 2026
2c577f5
fix(core): decouple monitor permission scope from Bash rules
doudouOUC Apr 29, 2026
6bf6376
fix(core): unify monitor error/exit cleanup to prevent resource leaks
doudouOUC Apr 29, 2026
944687f
fix(core): add user-skills-directory guard to monitor directory valid…
doudouOUC Apr 29, 2026
c8c1446
feat(core): add Monitor(...) permission namespace for monitor tool (#…
doudouOUC Apr 29, 2026
3c35a8f
fix(cli): decouple headless monitor lifetime from final result
doudouOUC Apr 29, 2026
da1f712
fix(cli): stabilize stream-json monitor session shutdown
doudouOUC Apr 29, 2026
2fb383c
fix(cli): deny monitor in headless approval defaults
doudouOUC Apr 29, 2026
dc9c62f
fix(cli): honor tool aliases in headless allow checks
doudouOUC Apr 30, 2026
9ca22e8
fix(core): address opus review — sleep regex, monitor cap, non-intera…
doudouOUC Apr 30, 2026
fd99c25
fix(cli): abort monitors and background shells on /clear
doudouOUC Apr 30, 2026
eb50fed
fix(cli): abort monitors on stream-json session shutdown
doudouOUC Apr 30, 2026
49d6228
test(cli): use content event type in stream tests
doudouOUC Apr 30, 2026
fb0d8c1
fix(cli): isolate session cleanup on clear and shutdown
doudouOUC Apr 30, 2026
cc2deb5
fix(cli): finalize session cleanup after drain
doudouOUC Apr 30, 2026
3aabffe
fix(core): close remaining monitor review gaps
doudouOUC Apr 30, 2026
c53741f
fix(core): preserve shell cwd in virtual permission checks
doudouOUC Apr 30, 2026
eac666b
fix(core): normalize trailing background ampersands
doudouOUC Apr 30, 2026
123a175
fix(core): align monitor permission and wrapper handling
doudouOUC Apr 30, 2026
b5de523
test(core): make monitor CI assertions cross-platform
doudouOUC Apr 30, 2026
2662706
fix(core): align monitor wrapper normalization
doudouOUC Apr 30, 2026
119f160
fix(core): normalize wrapped monitor commands
doudouOUC Apr 30, 2026
f13165b
fix(core): harden monitor headless edge cases
doudouOUC Apr 30, 2026
28f4023
fix(core): preserve monitor spawn errors
doudouOUC Apr 30, 2026
6826b62
fix(core): harden monitor register cleanup
doudouOUC May 1, 2026
90a8710
fix(core): parse monitor wrapper script token
doudouOUC May 1, 2026
8ab3a7d
merge: resolve conflicts with origin/main
doudouOUC May 1, 2026
98502c3
fix(core): address PR review comments for monitor tool
doudouOUC May 1, 2026
ddb77ba
merge: resolve conflicts with origin/main — unify abortAll notify option
doudouOUC May 1, 2026
0e0a1c6
fix(cli): resolve clear command typecheck issues
doudouOUC May 1, 2026
60a4c13
fix(core): preserve background tasks across shutdown abort
doudouOUC May 1, 2026
ce6ca5a
fix(core): close monitor review gaps
doudouOUC May 1, 2026
cde5c34
fix(core): address latest monitor review comments
doudouOUC May 1, 2026
919e8b6
fix(cli): handle monitors across session switches
doudouOUC May 1, 2026
a0c8c7b
test(core): cover aborted monitor startup
doudouOUC May 1, 2026
a010898
fix(core): address remaining monitor PR review comments
doudouOUC May 1, 2026
f01e182
fix(core): abort running monitors in MonitorRegistry.reset()
doudouOUC May 1, 2026
ba1a5da
fix(core): harden monitor notification XML and displayText
doudouOUC May 1, 2026
0ee664f
test(core): cover token-bucket throttling and commented-sleep bypass
doudouOUC May 1, 2026
3bc8622
fix(core): address 3 remaining copilot review comments
doudouOUC May 1, 2026
fc6c21b
fix(core): address review comments — sanitize, notify, kill logging, …
doudouOUC May 2, 2026
0037390
fix(core): resolve monitor tool typecheck errors
doudouOUC May 2, 2026
71adaa4
fix(monitor): add missing stdio option in taskkill test assertions (#…
Copilot May 2, 2026
71722f1
fix(core): address monitor review feedback
doudouOUC May 2, 2026
681dda2
fix(core): harden monitor command lifecycle
doudouOUC May 2, 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
44 changes: 44 additions & 0 deletions integration-tests/cli/monitor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, afterEach } from 'vitest';
import { TestRig, validateModelOutput } from '../test-helper.js';

describe('monitor-tool', () => {
let rig: TestRig;

afterEach(async () => {
if (rig) {
await rig.cleanup();
}
});

it('should have monitor tool registered', async () => {
rig = new TestRig();
await rig.setup('monitor-tool-registered');

const result = await rig.run(
'Do you have access to a tool called "monitor"? Reply with just "yes" or "no".',
);

validateModelOutput(result, null, 'monitor tool registered');
expect(result.toLowerCase()).toContain('yes');
});

it('should call monitor tool when asked to watch a command', async () => {
rig = new TestRig();
await rig.setup('monitor-tool-call');

const result = await rig.run(
'Use the monitor tool to watch this command: for i in 1 2 3; do echo "EVENT_$i"; sleep 0.3; done. ' +
'Set description to "test events". After starting the monitor, just say "Monitor launched."',
);

const foundMonitor = await rig.waitForToolCall('monitor');
expect(foundMonitor).toBeTruthy();
validateModelOutput(result, null, 'monitor tool call');
}, 30000);
});
55 changes: 55 additions & 0 deletions integration-tests/cli/sleep-interception.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, afterEach } from 'vitest';
import { TestRig, validateModelOutput } from '../test-helper.js';

describe('sleep-interception', () => {
let rig: TestRig;

afterEach(async () => {
if (rig) {
await rig.cleanup();
}
});

it('should block sleep >= 2s and mention Monitor in guidance', async () => {
rig = new TestRig();
await rig.setup('sleep-blocked');

const result = await rig.run(
'Run this exact shell command: sleep 5. ' +
'If the command is blocked, say "BLOCKED" and explain why. ' +
'If it succeeds, say "SUCCESS".',
);

validateModelOutput(result, null, 'sleep blocked');

// The model should report being blocked, since sleep 5 triggers interception
const foundShell = await rig.waitForToolCall('run_shell_command');
expect(foundShell).toBeTruthy();

// The model's output should mention it was blocked
expect(result.toLowerCase()).toContain('blocked');
Comment thread
doudouOUC marked this conversation as resolved.
}, 30000);

it('should allow sleep < 2s', async () => {
rig = new TestRig();
await rig.setup('sleep-allowed');

const result = await rig.run(
'Run this exact shell command: sleep 1. Then say "DONE".',
);

validateModelOutput(result, null, 'sleep allowed');

const foundShell = await rig.waitForToolCall('run_shell_command');
expect(foundShell).toBeTruthy();

// Should not be blocked — model should complete successfully
expect(result.toLowerCase()).not.toContain('blocked');
}, 30000);
});
6 changes: 6 additions & 0 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ describe('runNonInteractive', () => {
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
abortAll: vi.fn(),
}),
getMonitorRegistry: vi.fn().mockReturnValue({
setNotificationCallback: vi.fn(),
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
setRegisterCallback: vi.fn(),
getRunning: vi.fn().mockReturnValue([]),
abortAll: vi.fn(),
}),
} as unknown as Config;

mockSettings = {
Expand Down
36 changes: 35 additions & 1 deletion packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,31 @@ export async function runNonInteractive(
});
});

// Register monitor notification callback onto the shared queue.
const monitorRegistry = config.getMonitorRegistry();
monitorRegistry.setNotificationCallback(
(displayText, modelText, meta) => {
localQueue.push({
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
sdkNotification: {
task_id: meta.monitorId,
tool_use_id: meta.toolUseId,
status: meta.status,
},
});
},
);

monitorRegistry.setRegisterCallback((entry) => {
adapter.emitSystemMessage('task_started', {
task_id: entry.monitorId,
tool_use_id: entry.toolUseId,
description: entry.description,
});
});

let isFirstTurn = true;
let modelOverride: string | undefined;
while (true) {
Expand Down Expand Up @@ -705,6 +730,7 @@ export async function runNonInteractive(
while (true) {
if (abortController.signal.aborted) {
registry.abortAll();
monitorRegistry.abortAll();
// Flush queued terminal notifications before handleCancellationError
// exits so stream-json consumers always see a task_notification paired
// with every task_started.
Expand All @@ -718,7 +744,12 @@ export async function runNonInteractive(
// running ones: cancel() marks status 'cancelled' synchronously
// but the notification is emitted later by the natural handler,
// and SDK consumers need every task_started paired with one.
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
if (!registry.hasUnfinalizedTasks() && localQueue.length === 0)
// Also wait for running monitors to finish streaming events.
if (
!registry.hasUnfinalizedTasks() &&
monitorRegistry.getRunning().length === 0 &&
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
localQueue.length === 0
)
break;
await new Promise((r) => setTimeout(r, 100));
Comment thread
doudouOUC marked this conversation as resolved.
}
Expand Down Expand Up @@ -775,6 +806,9 @@ export async function runNonInteractive(
const reg = config.getBackgroundTaskRegistry();
reg.setNotificationCallback(undefined);
reg.setRegisterCallback(undefined);
const monReg = config.getMonitorRegistry();
monReg.setNotificationCallback(undefined);
monReg.setRegisterCallback(undefined);

process.stdout.removeListener('error', stdoutErrorHandler);
// Cleanup signal handlers
Expand Down
5 changes: 1 addition & 4 deletions packages/cli/src/ui/commands/tasksCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,7 @@ export const tasksCommand: SlashCommand = {
}

const now = Date.now();
const lines: string[] = [
`Background shells (${entries.length} total)`,
'',
];
const lines: string[] = [`Background shells (${entries.length} total)`, ''];
for (const entry of entries) {
const endTime = entry.endTime ?? now;
const runtime = formatDuration(endTime - entry.startTime, {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ describe('useGeminiStream', () => {
getBackgroundTaskRegistry: vi.fn(() => ({
setNotificationCallback: vi.fn(),
})),
getMonitorRegistry: vi.fn(() => ({
setNotificationCallback: vi.fn(),
})),
} as unknown as Config;
mockOnDebugMessage = vi.fn();
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2253,6 +2253,22 @@ export const useGeminiStream = (
};
}, [config]);

// Register monitor notification callback onto the shared queue.
useEffect(() => {
const registry = config.getMonitorRegistry();
registry.setNotificationCallback((displayText, modelText) => {
notificationQueueRef.current.push({
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
});
setNotificationTrigger((n) => n + 1);
});
return () => {
registry.setNotificationCallback(undefined);
};
}, [config]);

// When idle, drain the unified queue one item at a time.
useEffect(() => {
if (
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { PermissionManager } from '../permissions/permission-manager.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
import { MonitorRegistry } from '../services/monitorRegistry.js';
import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js';
import {
DEFAULT_OTLP_ENDPOINT,
Expand Down Expand Up @@ -546,6 +547,7 @@ export class Config {
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
private readonly backgroundTaskRegistry = new BackgroundTaskRegistry();
private readonly monitorRegistry = new MonitorRegistry();
private readonly backgroundShellRegistry = new BackgroundShellRegistry();
private extensionManager!: ExtensionManager;
private skillManager: SkillManager | null = null;
Expand Down Expand Up @@ -1612,6 +1614,7 @@ export class Config {
}

this.backgroundTaskRegistry.abortAll();
this.monitorRegistry.abortAll();
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
this.backgroundShellRegistry.abortAll();

await this.cleanupArenaRuntime();
Expand Down Expand Up @@ -2492,6 +2495,10 @@ export class Config {
return this.backgroundTaskRegistry;
}

getMonitorRegistry(): MonitorRegistry {
return this.monitorRegistry;
}

getBackgroundShellRegistry(): BackgroundShellRegistry {
return this.backgroundShellRegistry;
}
Expand Down Expand Up @@ -2745,6 +2752,12 @@ export class Config {
});
}

// Register monitor tool
await registerLazy(ToolNames.MONITOR, async () => {
Comment thread
doudouOUC marked this conversation as resolved.
const { MonitorTool } = await import('../tools/monitor.js');
return new MonitorTool(this);
});

if (!options?.skipDiscovery) {
await registry.discoverAllTools();
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export * from './services/sessionService.js';
export * from './services/sessionTitle.js';
export { stripTerminalControlSequences } from './utils/terminalSafe.js';
export * from './services/shellExecutionService.js';
export * from './services/monitorRegistry.js';
Comment thread
doudouOUC marked this conversation as resolved.
export * from './services/backgroundShellRegistry.js';
export * from './services/toolUseSummary.js';
export * from './utils/bareMode.js';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/permissions/permission-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export class PermissionManager {
'cron_create',
'cron_list',
'cron_delete',
'monitor',
Comment thread
doudouOUC marked this conversation as resolved.
Comment thread
doudouOUC marked this conversation as resolved.
]);

/**
Expand Down
Loading
Loading