Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,16 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两

设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。

Windows 未配置 `notify` 时会使用内置桌面提示;点击提示会尝试聚焦启动 CLI 的终端窗口。配置自定义 `notify` 后,会优先执行你的脚本。

通知脚本执行时,会通过环境变量注入以下上下文信息:

| 环境变量 | 说明 |
|----------|------|
| `DURATION` | 会话耗时,单位秒(整数) |
| `STATUS` | 会话状态:`"completed"` 或 `"failed"` |
| `FAIL_REASON` | 失败原因(仅失败时设置) |
| `QUESTION` | 最后一条用户问题的文本内容 |
| `BODY` | 最后一条 AI 助手回复的文本内容 |
| `TITLE` | 会话标题(对应 resume 列表中的标题) |

Expand Down
5 changes: 4 additions & 1 deletion docs/configuration_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,16 @@ When thinking mode is enabled, controls the depth of the model’s reasoning:

Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message).

On Windows, when `notify` is not configured, Deep Code uses the built-in desktop tip. Clicking the tip attempts to focus the terminal window that launched the CLI. A custom `notify` script takes precedence over the built-in tip.

The following context is injected as environment variables when the notify script runs:

| Variable | Description |
|----------|-------------|
| `DURATION` | Session duration in seconds (integer) |
| `STATUS` | Session status: `"completed"` or `"failed"` |
| `FAIL_REASON` | Failure reason (only set on failure) |
| `QUESTION` | The text content of the last user question |
| `BODY` | The text content of the last AI assistant reply |
| `TITLE` | Session title (matches the resume list title) |

Expand Down Expand Up @@ -193,4 +196,4 @@ Applied in the following priority order (lower-numbered overridden by higher-num
2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}`
3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}`
4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}`
5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode`
5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode`
3 changes: 3 additions & 0 deletions docs/notify.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。

在 Windows 上,如果未配置 `notify`,Deep Code 会使用内置桌面提示;点击提示会尝试聚焦启动 CLI 的终端窗口。配置自定义 `notify` 后,会优先执行你的脚本。

## 注入的环境变量

| 环境变量 | 说明 |
|----------|------|
| `DURATION` | 会话耗时,单位秒(整数) |
| `STATUS` | 会话状态:`"completed"` 或 `"failed"` |
| `FAIL_REASON` | 失败原因(仅失败时设置) |
| `QUESTION` | 最后一条用户问题的文本内容 |
| `BODY` | 最后一条 AI 助手回复的文本内容 |
| `TITLE` | 会话标题(对应 resume 列表中的标题) |

Expand Down
3 changes: 3 additions & 0 deletions docs/notify_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ When the AI assistant finishes a round of tasks, Deep Code can automatically exe

Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables.

On Windows, when `notify` is not configured, Deep Code uses the built-in desktop tip. Clicking the tip attempts to focus the terminal window that launched the CLI. A custom `notify` script takes precedence over the built-in tip.

## Injected Environment Variables

| Variable | Description |
|----------|-------------|
| `DURATION` | Session duration in seconds (integer) |
| `STATUS` | Session status: `"completed"` or `"failed"` |
| `FAIL_REASON` | Failure reason (only set on failure) |
| `QUESTION` | The text content of the last user question |
| `BODY` | The text content of the last AI assistant reply |
| `TITLE` | Session title (matches the resume list title) |

Expand Down
127 changes: 126 additions & 1 deletion src/common/notify.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { spawn, type SpawnOptions } from "child_process";
import { spawn, spawnSync, type SpawnOptions } from "child_process";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";

type NotifyChildProcess = {
once(event: "error", listener: (error: NodeJS.ErrnoException) => void): NotifyChildProcess;
Expand All @@ -19,6 +22,7 @@ export function formatDurationSeconds(durationMs: number): string {
export type NotifyContext = {
status?: string;
failReason?: string;
question?: string;
body?: string;
title?: string;
};
Expand All @@ -34,6 +38,7 @@ export function buildNotifyEnv(
};
delete env.STATUS;
delete env.FAIL_REASON;
delete env.QUESTION;
delete env.BODY;
delete env.TITLE;

Expand All @@ -43,6 +48,9 @@ export function buildNotifyEnv(
if (context.failReason) {
env.FAIL_REASON = context.failReason;
}
if (context.question) {
env.QUESTION = context.question;
}
if (context.body) {
env.BODY = context.body;
}
Expand Down Expand Up @@ -96,3 +104,120 @@ export function launchNotifyScript(
// Ignore notification failures.
}
}

/**
* Resolve the bundled built-in notification script shipped with the CLI.
* The esbuild ESM bundle does not inject `__dirname`, so we replicate the
* same fallback pattern used by `getExtensionRoot`.
*/
export function resolveBuiltinNotifyPath(): string | null {
if (process.platform !== "win32") {
return null;
}
try {
const moduleDir =
typeof __dirname !== "undefined"
? path.resolve(__dirname)
: path.resolve(path.dirname(fileURLToPath(import.meta.url)));
const candidates = [
path.resolve(moduleDir, "..", "templates", "tools", "deepcode-notify.ps1"),
path.resolve(moduleDir, "..", "..", "templates", "tools", "deepcode-notify.ps1"),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
} catch {
return null;
}
}

export function captureForegroundWindowHwnd(): string | undefined {
if (process.platform !== "win32") {
return undefined;
}

const script = [
"$code = @'",
"using System;",
"using System.Runtime.InteropServices;",
"public static class DCForeground {",
' [DllImport("user32.dll")]',
" public static extern IntPtr GetForegroundWindow();",
"}",
"'@",
"Add-Type -TypeDefinition $code",
"[DCForeground]::GetForegroundWindow().ToInt64()",
].join("\n");

try {
const result = spawnSync("powershell.exe", ["-ExecutionPolicy", "Bypass", "-NoProfile", "-Command", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 700,
windowsHide: true,
});
if (result.status !== 0) {
return undefined;
}
const hwnd = result.stdout.trim();
return /^[1-9]\d*$/.test(hwnd) ? hwnd : undefined;
} catch {
return undefined;
}
}

function getBuiltinNotifyEnv(
durationMs: number,
configuredEnv: Record<string, string>,
context: NotifyContext,
workingDirectory?: string
): NodeJS.ProcessEnv {
const env = buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context);
env.DEEPCODE_NOTIFY_PROCESS_PID ??= String(process.pid);
env.DEEPCODE_NOTIFY_PARENT_PID ??= String(process.ppid);

const debugEnabled = env.DEEPCODE_NOTIFY_DEBUG === "1" || env.DEEPCODE_NOTIFY_DEBUG === "true";
if (debugEnabled && !env.DEEPCODE_NOTIFY_DEBUG_LOG) {
env.DEEPCODE_NOTIFY_DEBUG_LOG = path.join(workingDirectory ?? process.cwd(), ".deepcode", "notify.log");
}

return env;
}

/**
* Launch the built-in Windows notification (PowerShell BalloonTip with
* click-to-focus behaviour). Has no effect on non-Windows platforms.
*
* This is intentionally separate from `launchNotifyScript` so that callers
* can decide whether to prefer a user-configured external script or the
* built-in one.
*/
export function launchBuiltinNotify(
durationMs: number,
workingDirectory?: string,
spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn,
configuredEnv: Record<string, string> = {},
context: NotifyContext = {}
): void {
const scriptPath = resolveBuiltinNotifyPath();
if (!scriptPath) {
return;
}

const options = {
cwd: workingDirectory,
detached: false,
env: getBuiltinNotifyEnv(durationMs, configuredEnv, context, workingDirectory),
stdio: "ignore" as const,
};

try {
const child = spawnProcess(
"powershell.exe",
["-ExecutionPolicy", "Bypass", "-NoProfile", "-File", scriptPath],
options
);
child.once("error", () => undefined);
child.unref();
} catch {
// Ignore notification failures.
}
}
42 changes: 30 additions & 12 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as crypto from "crypto";
import matter from "gray-matter";
import ejs from "ejs";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { launchNotifyScript } from "./common/notify";
import { captureForegroundWindowHwnd, launchBuiltinNotify, launchNotifyScript } from "./common/notify";
import { buildThinkingRequestOptions } from "./common/openai-thinking";
import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities";
import { readTextFileWithMetadata } from "./common/file-utils";
Expand Down Expand Up @@ -1199,6 +1199,8 @@ ${skillMd}
const startedAt = Date.now();
const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } =
this.createOpenAIClient();
const builtinNotifyWindowHwnd = process.platform === "win32" && !notify ? captureForegroundWindowHwnd() : undefined;
const notifyEnv = builtinNotifyWindowHwnd ? { ...env, DEEPCODE_NOTIFY_WINDOW_HWND: builtinNotifyWindowHwnd } : env;
const now = new Date().toISOString();
rebuildSessionStateFromHistory(sessionId, this.listSessionMessages(sessionId));

Expand All @@ -1217,7 +1219,7 @@ ${skillMd}
),
false
);
this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env);
this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv);
return;
}

Expand All @@ -1229,7 +1231,7 @@ ${skillMd}
failReason: "interrupted",
updateTime: now,
}));
this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env);
this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv);
return;
}

Expand Down Expand Up @@ -1434,7 +1436,7 @@ ${skillMd}
if (this.sessionControllers.get(sessionId) === sessionController) {
this.sessionControllers.delete(sessionId);
}
this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, env);
this.maybeNotifyTaskCompletion(sessionId, notify, startedAt, notifyEnv);
}
}

Expand Down Expand Up @@ -2423,32 +2425,48 @@ ${skillMd}
startedAt: number,
configuredEnv: Record<string, string> = {}
): void {
if (!notifyCommand) {
return;
}

const session = this.getSession(sessionId);
if (!session || (session.status !== "completed" && session.status !== "failed")) {
return;
}

// Find the last assistant message body for the BODY env variable.
// Find the latest user question and assistant answer for the desktop tip.
let question: string | undefined;
let body: string | undefined;
const messages = this.listSessionMessages(sessionId);
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg && msg.role === "assistant" && msg.content) {
if (!msg) {
continue;
}
if (!question && msg.role === "user" && msg.content?.trim()) {
question = msg.content;
}
if (!body && msg.role === "assistant" && msg.content?.trim()) {
body = msg.content;
}
if (question && body) {
break;
}
}

launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, {
const context = {
status: session.status,
failReason: session.failReason ?? undefined,
question,
body,
title: session.summary ?? undefined,
});
};

if (notifyCommand) {
launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, context);
return;
}

// Windows: fall back to the built-in toast notification with click-to-focus.
if (process.platform === "win32") {
launchBuiltinNotify(Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, context);
}
}

private addSessionProcess(sessionId: string, processId: string | number, command: string): void {
Expand Down
4 changes: 3 additions & 1 deletion src/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@ test(
const records = await waitForNotifyRecords(notifyOutput, 1);
assert.equal(records[0]?.STATUS, "completed");
assert.equal(records[0]?.FAIL_REASON, null);
assert.equal(records[0]?.QUESTION, "notify success");
assert.equal(records[0]?.BODY, "final answer");
assert.equal(records[0]?.TITLE, "notify success");
assert.match(String(records[0]?.DURATION), /^\d+$/);
Expand Down Expand Up @@ -1015,6 +1016,7 @@ test(
const failedRecord = records[1];
assert.equal(failedRecord?.STATUS, "failed");
assert.equal(failedRecord?.FAIL_REASON, "second request failed");
assert.equal(failedRecord?.QUESTION, "second prompt");
assert.equal(failedRecord?.BODY, "first answer");
assert.notEqual(failedRecord?.BODY, "stale-body");
assert.equal(failedRecord?.TITLE, "notify failure");
Expand Down Expand Up @@ -3306,7 +3308,7 @@ function createNotifyRecorderScript(dir: string): string {
scriptPath,
`#!/usr/bin/env node
const fs = require("fs");
const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"];
const keys = ["DURATION", "STATUS", "FAIL_REASON", "QUESTION", "BODY", "TITLE"];
const record = {};
for (const key of keys) {
record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null;
Expand Down
Loading