Skip to content

Commit 2874f98

Browse files
committed
feat(notify): pass STATUS, FAIL_REASON, BODY as env vars to notify hook
- Add NotifyContext type with status, failReason, body fields - buildNotifyEnv injects STATUS, FAIL_REASON, BODY when provided - maybeNotifyTaskCompletion extracts last assistant message as BODY - launchNotifyScript accepts optional context parameter - Add unit tests for new context env var injection - Update docs with env variable table and iTerm2/macOS notify examples
1 parent cc59ae1 commit 2874f98

5 files changed

Lines changed: 151 additions & 9 deletions

File tree

docs/configuration.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,45 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
6767

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

70+
通知脚本执行时,会通过环境变量注入以下上下文信息:
71+
72+
| 环境变量 | 说明 |
73+
|----------|------|
74+
| `DURATION` | 会话耗时,单位秒(整数) |
75+
| `STATUS` | 会话状态:`"completed"``"failed"` |
76+
| `FAIL_REASON` | 失败原因(仅失败时设置) |
77+
| `BODY` | 最后一条 AI 助手回复的文本内容 |
78+
7079
```json
7180
{
7281
"notify": "/path/to/slack-notify.sh"
7382
}
7483
```
7584

85+
**iTerm2 终端通知示例**
86+
87+
如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`):
88+
89+
```bash
90+
#!/bin/bash
91+
# iTerm2 OSC 9 通知
92+
echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
93+
```
94+
95+
```json
96+
{
97+
"notify": "/Users/you/.deepcode/notify.sh"
98+
}
99+
```
100+
101+
**macOS 系统通知示例**
102+
103+
```bash
104+
#!/bin/bash
105+
# macOS 系统通知
106+
osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
107+
```
108+
76109
#### `webSearchTool` — 自定义联网搜索
77110

78111
Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径:

docs/configuration_en.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,45 @@ When thinking mode is enabled, controls the depth of the model’s reasoning:
6767

6868
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).
6969

70+
The following context is injected as environment variables when the notify script runs:
71+
72+
| Variable | Description |
73+
|----------|-------------|
74+
| `DURATION` | Session duration in seconds (integer) |
75+
| `STATUS` | Session status: `"completed"` or `"failed"` |
76+
| `FAIL_REASON` | Failure reason (only set on failure) |
77+
| `BODY` | The text content of the last AI assistant reply |
78+
7079
```json
7180
{
7281
"notify": "/path/to/slack-notify.sh"
7382
}
7483
```
7584

85+
**iTerm2 Notification Example**:
86+
87+
On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`):
88+
89+
```bash
90+
#!/bin/bash
91+
# iTerm2 OSC 9 notification
92+
echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
93+
```
94+
95+
```json
96+
{
97+
"notify": "/Users/you/.deepcode/notify.sh"
98+
}
99+
```
100+
101+
**macOS System Notification Example**:
102+
103+
```bash
104+
#!/bin/bash
105+
# macOS system notification
106+
osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
107+
```
108+
76109
#### `webSearchTool` — Custom Web Search
77110

78111
Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script:

src/common/notify.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,40 @@ export function formatDurationSeconds(durationMs: number): string {
1616
return String(Math.floor(safeMs / 1000));
1717
}
1818

19-
export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
20-
return {
19+
export type NotifyContext = {
20+
status?: string;
21+
failReason?: string;
22+
body?: string;
23+
};
24+
25+
export function buildNotifyEnv(
26+
durationMs: number,
27+
baseEnv: NodeJS.ProcessEnv = process.env,
28+
context: NotifyContext = {}
29+
): NodeJS.ProcessEnv {
30+
const env: NodeJS.ProcessEnv = {
2131
...baseEnv,
2232
DURATION: formatDurationSeconds(durationMs),
2333
};
34+
if (context.status) {
35+
env.STATUS = context.status;
36+
}
37+
if (context.failReason) {
38+
env.FAIL_REASON = context.failReason;
39+
}
40+
if (context.body) {
41+
env.BODY = context.body;
42+
}
43+
return env;
2444
}
2545

2646
export function launchNotifyScript(
2747
notifyPath: string | undefined,
2848
durationMs: number,
2949
workingDirectory?: string,
3050
spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn,
31-
configuredEnv: Record<string, string> = {}
51+
configuredEnv: Record<string, string> = {},
52+
context: NotifyContext = {}
3253
): void {
3354
const commandPath = notifyPath?.trim();
3455
if (!commandPath) {
@@ -38,7 +59,7 @@ export function launchNotifyScript(
3859
const options = {
3960
cwd: workingDirectory,
4061
detached: process.platform !== "win32",
41-
env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }),
62+
env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context),
4263
stdio: "ignore" as const,
4364
};
4465

src/session.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2124,7 +2124,22 @@ ${skillMd}
21242124
return;
21252125
}
21262126

2127-
launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv);
2127+
// Find the last assistant message body for the BODY env variable.
2128+
let body: string | undefined;
2129+
const messages = this.listSessionMessages(sessionId);
2130+
for (let i = messages.length - 1; i >= 0; i--) {
2131+
const msg = messages[i];
2132+
if (msg && msg.role === "assistant" && msg.content) {
2133+
body = msg.content;
2134+
break;
2135+
}
2136+
}
2137+
2138+
launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, {
2139+
status: session.status,
2140+
failReason: session.failReason ?? undefined,
2141+
body,
2142+
});
21282143
}
21292144

21302145
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {

src/tests/settings-and-notify.test.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
3-
import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../common/notify";
3+
import {
4+
buildNotifyEnv,
5+
formatDurationSeconds,
6+
launchNotifyScript,
7+
type NotifyContext,
8+
type NotifySpawn,
9+
} from "../common/notify";
410
import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings";
511

612
const TEST_PROCESS_ENV = {};
@@ -358,14 +364,38 @@ test("formatDurationSeconds preserves sub-second precision and trims trailing ze
358364
assert.equal(formatDurationSeconds(4000), "4");
359365
});
360366

361-
test("buildNotifyEnv injects DURATION", () => {
367+
test("buildNotifyEnv injects DURATION without context", () => {
362368
const env = buildNotifyEnv(2750, { HOME: "/tmp/home" });
363369
assert.equal(env.HOME, "/tmp/home");
364370
assert.equal(env.DURATION, "2");
371+
assert.equal(env.STATUS, undefined);
372+
assert.equal(env.FAIL_REASON, undefined);
373+
assert.equal(env.BODY, undefined);
374+
});
375+
376+
test("buildNotifyEnv injects STATUS, FAIL_REASON, and BODY from context", () => {
377+
const context: NotifyContext = {
378+
status: "failed",
379+
failReason: "API key not found",
380+
body: "Hello, this is the last assistant message.",
381+
};
382+
const env = buildNotifyEnv(5000, { HOME: "/tmp/home" }, context);
383+
assert.equal(env.HOME, "/tmp/home");
384+
assert.equal(env.DURATION, "5");
385+
assert.equal(env.STATUS, "failed");
386+
assert.equal(env.FAIL_REASON, "API key not found");
387+
assert.equal(env.BODY, "Hello, this is the last assistant message.");
388+
});
389+
390+
test("buildNotifyEnv omits optional context fields when not provided", () => {
391+
const env = buildNotifyEnv(1000, { HOME: "/tmp/home" }, { status: "completed" });
392+
assert.equal(env.STATUS, "completed");
393+
assert.equal(env.FAIL_REASON, undefined);
394+
assert.equal(env.BODY, undefined);
365395
});
366396

367397
test(
368-
"launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts",
398+
"launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts",
369399
{ skip: process.platform === "win32" },
370400
() => {
371401
const calls: Array<{
@@ -390,17 +420,27 @@ test(
390420
};
391421
};
392422

393-
launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" });
423+
const context: NotifyContext = {
424+
status: "completed",
425+
body: "Task finished successfully.",
426+
};
427+
428+
launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }, context);
394429

395430
assert.equal(calls.length, 2);
396431
assert.equal(calls[0]?.command, "/tmp/notify.sh");
397432
assert.deepEqual(calls[0]?.args, []);
398433
assert.equal(calls[0]?.options.cwd, "/tmp/project");
399434
assert.equal(calls[0]?.options.env?.DURATION, "2");
400435
assert.equal(calls[0]?.options.env?.WEBHOOK, "configured");
436+
assert.equal(calls[0]?.options.env?.STATUS, "completed");
437+
assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined);
438+
assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully.");
401439
assert.equal(calls[1]?.command, "/bin/sh");
402440
assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]);
403441
assert.equal(calls[1]?.options.cwd, "/tmp/project");
404442
assert.equal(calls[1]?.options.env?.DURATION, "2");
443+
assert.equal(calls[1]?.options.env?.STATUS, "completed");
444+
assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully.");
405445
}
406446
);

0 commit comments

Comments
 (0)