@daandden/notification-hooks 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +156 -0
- package/package.json +77 -0
- package/src/config.ts +72 -0
- package/src/index.ts +188 -0
- package/src/notify.ts +88 -0
- package/src/payload.ts +103 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Notification Hooks
|
|
2
|
+
|
|
3
|
+
`@daandden/notification-hooks` is an installable OMP plugin that forwards selected hook lifecycle events to a shell command.
|
|
4
|
+
|
|
5
|
+
v1 emits two events:
|
|
6
|
+
- `ask_waiting` when the `ask` tool is waiting for user input
|
|
7
|
+
- `completion` when an agent run has fully settled after retries and auto-compaction
|
|
8
|
+
|
|
9
|
+
The hook sends the structured payload on stdin and mirrors key metadata through `OMP_NOTIFY_*` environment variables.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
omp plugin install @daandden/notification-hooks
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configure
|
|
18
|
+
|
|
19
|
+
Set the command and optional behavior with `omp plugin config` commands:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
omp plugin config set @daandden/notification-hooks command 'notify-send "$OMP_NOTIFY_EVENT" "$OMP_NOTIFY_MESSAGE"'
|
|
23
|
+
omp plugin config set @daandden/notification-hooks shell /bin/sh
|
|
24
|
+
omp plugin config set @daandden/notification-hooks timeoutMs 5000
|
|
25
|
+
omp plugin config set @daandden/notification-hooks notifyOnCompletion true
|
|
26
|
+
omp plugin config set @daandden/notification-hooks notifyOnAsk true
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Inspect current settings:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
omp plugin config list @daandden/notification-hooks
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Settings
|
|
36
|
+
|
|
37
|
+
| Setting | Type | Default | Purpose |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `command` | string | disabled | Shell command to run. Empty or unset disables notifications. |
|
|
40
|
+
| `shell` | string | `/bin/sh` | Shell binary used to execute `command`. |
|
|
41
|
+
| `timeoutMs` | number | `5000` | Maximum time before the subprocess is killed and logged as a warning. |
|
|
42
|
+
| `notifyOnCompletion` | boolean | `true` | Emit `completion` notifications. |
|
|
43
|
+
| `notifyOnAsk` | boolean | `true` | Emit `ask_waiting` notifications. |
|
|
44
|
+
|
|
45
|
+
## Environment variables
|
|
46
|
+
|
|
47
|
+
Each setting also has an env fallback:
|
|
48
|
+
- `OMP_NOTIFICATION_COMMAND`
|
|
49
|
+
- `OMP_NOTIFICATION_SHELL`
|
|
50
|
+
- `OMP_NOTIFICATION_TIMEOUT_MS`
|
|
51
|
+
- `OMP_NOTIFICATION_NOTIFY_ON_COMPLETION`
|
|
52
|
+
- `OMP_NOTIFICATION_NOTIFY_ON_ASK`
|
|
53
|
+
|
|
54
|
+
Every notification subprocess receives these payload-derived env vars:
|
|
55
|
+
- `OMP_NOTIFY_EVENT`
|
|
56
|
+
- `OMP_NOTIFY_MESSAGE`
|
|
57
|
+
- `OMP_NOTIFY_CWD`
|
|
58
|
+
- `OMP_NOTIFY_TIMESTAMP`
|
|
59
|
+
- `OMP_NOTIFY_SESSION_NAME`
|
|
60
|
+
- `OMP_NOTIFY_SESSION_FILE`
|
|
61
|
+
- `OMP_NOTIFY_TOOL_NAME`
|
|
62
|
+
- `OMP_NOTIFY_QUESTION_COUNT`
|
|
63
|
+
|
|
64
|
+
## Example commands
|
|
65
|
+
|
|
66
|
+
### macOS via osascript
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
omp plugin config set @daandden/notification-hooks command 'osascript -e "display notification \"$OMP_NOTIFY_MESSAGE\" with title \"OMP\" subtitle \"$OMP_NOTIFY_EVENT\""'
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Linux via notify-send
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
omp plugin config set @daandden/notification-hooks command 'notify-send "$OMP_NOTIFY_EVENT" "$OMP_NOTIFY_MESSAGE"'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Webhook bridge
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
omp plugin config set @daandden/notification-hooks command 'payload=$(cat); jq -n --argjson body "$payload" '{"'"'text'"'": $body.message, "'"'event'"'": $body.event, "'"'session'"'": $body.session}' | curl -fsS -H '"'"'content-type: application/json'"'"' -d @- https://example.invalid/hooks/omp'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Capture payloads to disk
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
omp plugin config set @daandden/notification-hooks command 'cat > /tmp/omp-notify.json'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## JSON payload
|
|
91
|
+
|
|
92
|
+
Example `ask_waiting` payload:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"event": "ask_waiting",
|
|
97
|
+
"message": "Demo session: Waiting for input",
|
|
98
|
+
"timestamp": 1742371200000,
|
|
99
|
+
"cwd": "/workspace/project",
|
|
100
|
+
"session": {
|
|
101
|
+
"name": "Demo session",
|
|
102
|
+
"file": "/workspace/.omp/agent/sessions/demo.jsonl"
|
|
103
|
+
},
|
|
104
|
+
"toolName": "ask",
|
|
105
|
+
"ask": {
|
|
106
|
+
"questionCount": 1,
|
|
107
|
+
"questions": [
|
|
108
|
+
{
|
|
109
|
+
"id": "deploy",
|
|
110
|
+
"question": "Ship this change?",
|
|
111
|
+
"options": ["Yes", "No"],
|
|
112
|
+
"multi": false,
|
|
113
|
+
"recommended": 0
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Example `completion` payload:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"event": "completion",
|
|
125
|
+
"message": "Complete",
|
|
126
|
+
"timestamp": 1742371205000,
|
|
127
|
+
"cwd": "/workspace/project",
|
|
128
|
+
"session": {
|
|
129
|
+
"file": "/workspace/.omp/agent/sessions/demo.jsonl"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Lifecycle behavior
|
|
135
|
+
|
|
136
|
+
Completion notifications are intentionally deferred. The hook treats `agent_end` as a completion candidate, then cancels or reschedules based on follow-up lifecycle events:
|
|
137
|
+
- cancel on `auto_retry_start`
|
|
138
|
+
- cancel on `auto_compaction_start`
|
|
139
|
+
- cancel on `turn_start`
|
|
140
|
+
- cancel on `session_shutdown`
|
|
141
|
+
- reschedule on `auto_retry_end` when `success === false`
|
|
142
|
+
- reschedule on `auto_compaction_end` when `willRetry === false` and the context is still idle with no queued messages
|
|
143
|
+
|
|
144
|
+
This prevents false-positive completion notifications during retry or auto-compaction recovery.
|
|
145
|
+
|
|
146
|
+
## Manual smoke test
|
|
147
|
+
|
|
148
|
+
1. Install the plugin: `omp plugin install @daandden/notification-hooks`
|
|
149
|
+
2. Configure a capture command: `omp plugin config set @daandden/notification-hooks command 'cat > /tmp/omp-notify.json'`
|
|
150
|
+
3. Trigger an `ask` flow and confirm `/tmp/omp-notify.json` contains an `ask_waiting` payload.
|
|
151
|
+
4. Let the agent fully settle and confirm the file contains exactly one final `completion` payload, not an intermediate retry or compaction payload.
|
|
152
|
+
5. If the command fails or times out, inspect `~/.omp/logs/omp.YYYY-MM-DD.log` for warning entries.
|
|
153
|
+
|
|
154
|
+
## Failure behavior
|
|
155
|
+
|
|
156
|
+
Notification subprocess failures are non-fatal. Non-zero exit codes, spawn errors, and timeouts are logged through `@oh-my-pi/pi-utils`, but they do not fail or block the agent workflow.
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@daandden/notification-hooks",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "OMP plugin package for external notification hooks",
|
|
6
|
+
"author": "daandden",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": ["omp", "plugin", "hooks", "notifications"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"check": "tsgo -p tsconfig.json",
|
|
11
|
+
"test": "bun test test/notification-hooks.test.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@oh-my-pi/pi-coding-agent": "^13.13.2",
|
|
15
|
+
"@oh-my-pi/pi-utils": "^13.13.2"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "^1.3"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": ">=1.3.7"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
},
|
|
27
|
+
"omp": {
|
|
28
|
+
"hooks": "./src/index.ts",
|
|
29
|
+
"settings": {
|
|
30
|
+
"command": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Shell command to run for notification events. Leave empty to disable notifications.",
|
|
33
|
+
"env": "OMP_NOTIFICATION_COMMAND"
|
|
34
|
+
},
|
|
35
|
+
"shell": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "Shell executable used to invoke the notification command.",
|
|
38
|
+
"default": "/bin/sh",
|
|
39
|
+
"env": "OMP_NOTIFICATION_SHELL"
|
|
40
|
+
},
|
|
41
|
+
"timeoutMs": {
|
|
42
|
+
"type": "number",
|
|
43
|
+
"description": "Maximum time to wait for a notification command before it is killed.",
|
|
44
|
+
"default": 5000,
|
|
45
|
+
"min": 1,
|
|
46
|
+
"env": "OMP_NOTIFICATION_TIMEOUT_MS"
|
|
47
|
+
},
|
|
48
|
+
"notifyOnCompletion": {
|
|
49
|
+
"type": "boolean",
|
|
50
|
+
"description": "Send a notification when an agent run fully settles.",
|
|
51
|
+
"default": true,
|
|
52
|
+
"env": "OMP_NOTIFICATION_NOTIFY_ON_COMPLETION"
|
|
53
|
+
},
|
|
54
|
+
"notifyOnAsk": {
|
|
55
|
+
"type": "boolean",
|
|
56
|
+
"description": "Send a notification when the ask tool is waiting for input.",
|
|
57
|
+
"default": true,
|
|
58
|
+
"env": "OMP_NOTIFICATION_NOTIFY_ON_ASK"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"files": ["src", "README.md", "CHANGELOG.md"],
|
|
63
|
+
"exports": {
|
|
64
|
+
"./config": {
|
|
65
|
+
"types": "./src/config.ts",
|
|
66
|
+
"import": "./src/config.ts"
|
|
67
|
+
},
|
|
68
|
+
"./payload": {
|
|
69
|
+
"types": "./src/payload.ts",
|
|
70
|
+
"import": "./src/payload.ts"
|
|
71
|
+
},
|
|
72
|
+
"./notify": {
|
|
73
|
+
"types": "./src/notify.ts",
|
|
74
|
+
"import": "./src/notify.ts"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getPluginSettings } from "@oh-my-pi/pi-coding-agent/extensibility/plugins";
|
|
2
|
+
|
|
3
|
+
export type NotificationEventType = "ask_waiting" | "completion";
|
|
4
|
+
|
|
5
|
+
export interface HookNotificationConfig {
|
|
6
|
+
command: string | null;
|
|
7
|
+
shell: string;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
notifyOnCompletion: boolean;
|
|
10
|
+
notifyOnAsk: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PLUGIN_NAME = "@daandden/notification-hooks";
|
|
14
|
+
const DEFAULT_SHELL = "/bin/sh";
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
16
|
+
const DEFAULT_NOTIFY_ON_COMPLETION = true;
|
|
17
|
+
const DEFAULT_NOTIFY_ON_ASK = true;
|
|
18
|
+
|
|
19
|
+
function parseNonEmptyString(value: unknown): string | undefined {
|
|
20
|
+
if (typeof value !== "string") return undefined;
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseCommand(value: unknown): string | null | undefined {
|
|
26
|
+
if (value === null) return null;
|
|
27
|
+
if (typeof value !== "string") return undefined;
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseTimeoutMs(value: unknown): number | undefined {
|
|
33
|
+
const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : Number.NaN;
|
|
34
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return undefined;
|
|
35
|
+
return Math.floor(numeric);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseBoolean(value: unknown): boolean | undefined {
|
|
39
|
+
if (typeof value === "boolean") return value;
|
|
40
|
+
if (typeof value !== "string") return undefined;
|
|
41
|
+
const normalized = value.trim().toLowerCase();
|
|
42
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
43
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function resolveHookNotificationConfig(cwd: string): Promise<HookNotificationConfig> {
|
|
48
|
+
const pluginSettings = await getPluginSettings(PLUGIN_NAME, cwd);
|
|
49
|
+
|
|
50
|
+
const command = parseCommand(pluginSettings.command) ?? parseCommand(Bun.env.OMP_NOTIFICATION_COMMAND) ?? null;
|
|
51
|
+
const shell = parseNonEmptyString(pluginSettings.shell) ?? parseNonEmptyString(Bun.env.OMP_NOTIFICATION_SHELL) ?? DEFAULT_SHELL;
|
|
52
|
+
const timeoutMs =
|
|
53
|
+
parseTimeoutMs(pluginSettings.timeoutMs) ??
|
|
54
|
+
parseTimeoutMs(Bun.env.OMP_NOTIFICATION_TIMEOUT_MS) ??
|
|
55
|
+
DEFAULT_TIMEOUT_MS;
|
|
56
|
+
const notifyOnCompletion =
|
|
57
|
+
parseBoolean(pluginSettings.notifyOnCompletion) ??
|
|
58
|
+
parseBoolean(Bun.env.OMP_NOTIFICATION_NOTIFY_ON_COMPLETION) ??
|
|
59
|
+
DEFAULT_NOTIFY_ON_COMPLETION;
|
|
60
|
+
const notifyOnAsk =
|
|
61
|
+
parseBoolean(pluginSettings.notifyOnAsk) ??
|
|
62
|
+
parseBoolean(Bun.env.OMP_NOTIFICATION_NOTIFY_ON_ASK) ??
|
|
63
|
+
DEFAULT_NOTIFY_ON_ASK;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
command,
|
|
67
|
+
shell,
|
|
68
|
+
timeoutMs,
|
|
69
|
+
notifyOnCompletion,
|
|
70
|
+
notifyOnAsk,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutoCompactionEndEvent,
|
|
3
|
+
HookAPI,
|
|
4
|
+
HookContext,
|
|
5
|
+
ToolCallEvent,
|
|
6
|
+
} from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
|
|
7
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
8
|
+
import { buildAskWaitingPayload, buildCompletionPayload, type AskQuestionPayload, type NotificationPayload } from "./payload";
|
|
9
|
+
import { resolveHookNotificationConfig } from "./config";
|
|
10
|
+
import { runHookNotification } from "./notify";
|
|
11
|
+
|
|
12
|
+
interface SessionMetadata {
|
|
13
|
+
cwd: string;
|
|
14
|
+
sessionFile?: string;
|
|
15
|
+
sessionName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === "object" && value !== null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseNonEmptyString(value: unknown): string | undefined {
|
|
23
|
+
if (typeof value !== "string") return undefined;
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseBoolean(value: unknown): boolean {
|
|
29
|
+
return value === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseOptionLabels(value: unknown): string[] | undefined {
|
|
33
|
+
if (!Array.isArray(value)) return undefined;
|
|
34
|
+
const labels: string[] = [];
|
|
35
|
+
for (const option of value) {
|
|
36
|
+
if (!isRecord(option)) return undefined;
|
|
37
|
+
const label = parseNonEmptyString(option.label);
|
|
38
|
+
if (label === undefined) return undefined;
|
|
39
|
+
labels.push(label);
|
|
40
|
+
}
|
|
41
|
+
return labels;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseRecommendedIndex(value: unknown, optionCount: number): number | undefined {
|
|
45
|
+
if (typeof value !== "number") return undefined;
|
|
46
|
+
if (!Number.isInteger(value) || value < 0 || value >= optionCount) return undefined;
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseAskQuestion(value: unknown): AskQuestionPayload | undefined {
|
|
51
|
+
if (!isRecord(value)) return undefined;
|
|
52
|
+
const id = parseNonEmptyString(value.id);
|
|
53
|
+
const question = parseNonEmptyString(value.question);
|
|
54
|
+
const options = parseOptionLabels(value.options);
|
|
55
|
+
if (id === undefined || question === undefined || options === undefined) return undefined;
|
|
56
|
+
const recommended = parseRecommendedIndex(value.recommended, options.length);
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
question,
|
|
60
|
+
options,
|
|
61
|
+
multi: parseBoolean(value.multi),
|
|
62
|
+
...(recommended === undefined ? {} : { recommended }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseAskQuestions(input: Record<string, unknown>): AskQuestionPayload[] | null {
|
|
67
|
+
if (!Array.isArray(input.questions)) return null;
|
|
68
|
+
const questions: AskQuestionPayload[] = [];
|
|
69
|
+
for (const question of input.questions) {
|
|
70
|
+
const parsedQuestion = parseAskQuestion(question);
|
|
71
|
+
if (parsedQuestion === undefined) return null;
|
|
72
|
+
questions.push(parsedQuestion);
|
|
73
|
+
}
|
|
74
|
+
return questions;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getSessionMetadata(ctx: HookContext): SessionMetadata {
|
|
78
|
+
const header = ctx.sessionManager.getHeader();
|
|
79
|
+
return {
|
|
80
|
+
cwd: ctx.cwd,
|
|
81
|
+
sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
|
|
82
|
+
sessionName: parseNonEmptyString(header?.title),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function dispatchNotification(ctx: HookContext, payload: NotificationPayload): Promise<void> {
|
|
87
|
+
try {
|
|
88
|
+
const config = await resolveHookNotificationConfig(ctx.cwd);
|
|
89
|
+
if (payload.event === "ask_waiting" && !config.notifyOnAsk) return;
|
|
90
|
+
if (payload.event === "completion" && !config.notifyOnCompletion) return;
|
|
91
|
+
await runHookNotification(config, payload);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
logger.warn("Notification hook dispatch failed", {
|
|
94
|
+
event: payload.event,
|
|
95
|
+
error: error instanceof Error ? error.message : String(error),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildAskPayload(event: ToolCallEvent, ctx: HookContext): NotificationPayload | undefined {
|
|
101
|
+
if (event.toolName !== "ask") return undefined;
|
|
102
|
+
const questions = parseAskQuestions(event.input);
|
|
103
|
+
if (questions === null || questions.length === 0) return undefined;
|
|
104
|
+
return buildAskWaitingPayload({
|
|
105
|
+
...getSessionMetadata(ctx),
|
|
106
|
+
questions,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default function notificationHooks(pi: HookAPI): void {
|
|
111
|
+
let completionRevision = 0;
|
|
112
|
+
let completionTimer: NodeJS.Timeout | undefined;
|
|
113
|
+
let sessionShuttingDown = false;
|
|
114
|
+
|
|
115
|
+
function cancelCompletionCandidate(): void {
|
|
116
|
+
completionRevision += 1;
|
|
117
|
+
if (completionTimer) {
|
|
118
|
+
clearTimeout(completionTimer);
|
|
119
|
+
completionTimer = undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function emitCompletionIfCurrent(ctx: HookContext, revision: number): Promise<void> {
|
|
124
|
+
if (sessionShuttingDown || revision !== completionRevision) return;
|
|
125
|
+
// Re-check current agent state at fire time because queued work can arrive
|
|
126
|
+
// after agent_end/auto_retry_end but before the deferred timer runs.
|
|
127
|
+
if (!ctx.isIdle() || ctx.hasQueuedMessages()) return;
|
|
128
|
+
await dispatchNotification(ctx, buildCompletionPayload(getSessionMetadata(ctx)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scheduleCompletionCandidate(ctx: HookContext): void {
|
|
132
|
+
if (sessionShuttingDown) return;
|
|
133
|
+
const revision = completionRevision + 1;
|
|
134
|
+
completionRevision = revision;
|
|
135
|
+
if (completionTimer) clearTimeout(completionTimer);
|
|
136
|
+
// Hooks await handler promises. Defer the completion candidate so retry or
|
|
137
|
+
// compaction follow-up events can cancel it without blocking the agent loop.
|
|
138
|
+
completionTimer = setTimeout(() => {
|
|
139
|
+
completionTimer = undefined;
|
|
140
|
+
void emitCompletionIfCurrent(ctx, revision);
|
|
141
|
+
}, 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function maybeRescheduleAfterCompaction(event: AutoCompactionEndEvent, ctx: HookContext): void {
|
|
145
|
+
if (event.willRetry) return;
|
|
146
|
+
if (!ctx.isIdle() || ctx.hasQueuedMessages()) return;
|
|
147
|
+
scheduleCompletionCandidate(ctx);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pi.on("tool_call", (event, ctx) => {
|
|
151
|
+
const payload = buildAskPayload(event, ctx);
|
|
152
|
+
if (payload) {
|
|
153
|
+
void dispatchNotification(ctx, payload);
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
159
|
+
scheduleCompletionCandidate(ctx);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
pi.on("auto_retry_start", () => {
|
|
163
|
+
cancelCompletionCandidate();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
pi.on("auto_compaction_start", () => {
|
|
167
|
+
cancelCompletionCandidate();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.on("turn_start", () => {
|
|
171
|
+
cancelCompletionCandidate();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
pi.on("auto_retry_end", (event, ctx) => {
|
|
175
|
+
if (!event.success) {
|
|
176
|
+
scheduleCompletionCandidate(ctx);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("auto_compaction_end", (event, ctx) => {
|
|
181
|
+
maybeRescheduleAfterCompaction(event, ctx);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.on("session_shutdown", () => {
|
|
185
|
+
sessionShuttingDown = true;
|
|
186
|
+
cancelCompletionCandidate();
|
|
187
|
+
});
|
|
188
|
+
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import type { Subprocess } from "bun";
|
|
3
|
+
import type { HookNotificationConfig } from "./config";
|
|
4
|
+
import { buildNotificationEnv, type NotificationPayload } from "./payload";
|
|
5
|
+
|
|
6
|
+
const payloadEncoder = new TextEncoder();
|
|
7
|
+
|
|
8
|
+
function trimStderr(stderr: string): string | undefined {
|
|
9
|
+
const trimmed = stderr.trim();
|
|
10
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function safeKill(proc: Subprocess<"pipe", "ignore", "pipe">): void {
|
|
14
|
+
try {
|
|
15
|
+
proc.kill("SIGKILL");
|
|
16
|
+
} catch {
|
|
17
|
+
// Process already exited.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runHookNotification(config: HookNotificationConfig, payload: NotificationPayload): Promise<void> {
|
|
22
|
+
if (config.command === null) return;
|
|
23
|
+
|
|
24
|
+
const payloadJson = JSON.stringify(payload);
|
|
25
|
+
const env = {
|
|
26
|
+
...Bun.env,
|
|
27
|
+
...buildNotificationEnv(payload),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const proc = Bun.spawn([config.shell, "-lc", config.command], {
|
|
32
|
+
cwd: payload.cwd,
|
|
33
|
+
env,
|
|
34
|
+
stdin: "pipe",
|
|
35
|
+
stdout: "ignore",
|
|
36
|
+
stderr: "pipe",
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
});
|
|
39
|
+
const stderrPromise = new Response(proc.stderr).text();
|
|
40
|
+
let timedOut = false;
|
|
41
|
+
const timeoutId = setTimeout(() => {
|
|
42
|
+
timedOut = true;
|
|
43
|
+
safeKill(proc);
|
|
44
|
+
}, config.timeoutMs);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
proc.stdin.write(payloadEncoder.encode(payloadJson));
|
|
48
|
+
proc.stdin.end();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
clearTimeout(timeoutId);
|
|
51
|
+
safeKill(proc);
|
|
52
|
+
logger.warn("Notification hook stdin write failed", {
|
|
53
|
+
event: payload.event,
|
|
54
|
+
command: config.command,
|
|
55
|
+
error: error instanceof Error ? error.message : String(error),
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise]);
|
|
61
|
+
clearTimeout(timeoutId);
|
|
62
|
+
|
|
63
|
+
if (timedOut) {
|
|
64
|
+
logger.warn("Notification hook timed out", {
|
|
65
|
+
event: payload.event,
|
|
66
|
+
command: config.command,
|
|
67
|
+
timeoutMs: config.timeoutMs,
|
|
68
|
+
stderr: trimStderr(stderr),
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (exitCode !== 0) {
|
|
74
|
+
logger.warn("Notification hook command failed", {
|
|
75
|
+
event: payload.event,
|
|
76
|
+
command: config.command,
|
|
77
|
+
exitCode,
|
|
78
|
+
stderr: trimStderr(stderr),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.warn("Notification hook spawn failed", {
|
|
83
|
+
event: payload.event,
|
|
84
|
+
command: config.command,
|
|
85
|
+
error: error instanceof Error ? error.message : String(error),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/payload.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { NotificationEventType } from "./config";
|
|
2
|
+
|
|
3
|
+
export interface AskQuestionPayload {
|
|
4
|
+
id: string;
|
|
5
|
+
question: string;
|
|
6
|
+
options: string[];
|
|
7
|
+
multi: boolean;
|
|
8
|
+
recommended?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NotificationPayload {
|
|
12
|
+
event: NotificationEventType;
|
|
13
|
+
message: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
cwd: string;
|
|
16
|
+
session: { name?: string; file?: string };
|
|
17
|
+
toolName?: string;
|
|
18
|
+
ask?: { questionCount: number; questions: AskQuestionPayload[] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AskWaitingPayloadInput {
|
|
22
|
+
cwd: string;
|
|
23
|
+
sessionName?: string;
|
|
24
|
+
sessionFile?: string;
|
|
25
|
+
questions: AskQuestionPayload[];
|
|
26
|
+
timestamp?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CompletionPayloadInput {
|
|
30
|
+
cwd: string;
|
|
31
|
+
sessionName?: string;
|
|
32
|
+
sessionFile?: string;
|
|
33
|
+
timestamp?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildSession(sessionName?: string, sessionFile?: string): NotificationPayload["session"] {
|
|
37
|
+
return {
|
|
38
|
+
name: sessionName,
|
|
39
|
+
file: sessionFile,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildMessage(prefix: string, sessionName?: string): string {
|
|
44
|
+
const trimmedName = sessionName?.trim();
|
|
45
|
+
return trimmedName ? `${trimmedName}: ${prefix}` : prefix;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cloneQuestion(question: AskQuestionPayload): AskQuestionPayload {
|
|
49
|
+
return {
|
|
50
|
+
id: question.id,
|
|
51
|
+
question: question.question,
|
|
52
|
+
options: [...question.options],
|
|
53
|
+
multi: question.multi,
|
|
54
|
+
...(question.recommended === undefined ? {} : { recommended: question.recommended }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setEnvValue(env: Record<string, string>, key: string, value: string | number | undefined): void {
|
|
59
|
+
if (value === undefined) return;
|
|
60
|
+
env[key] = String(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildNotificationEnv(payload: NotificationPayload): Record<string, string> {
|
|
64
|
+
const env: Record<string, string> = {
|
|
65
|
+
OMP_NOTIFY_EVENT: payload.event,
|
|
66
|
+
OMP_NOTIFY_MESSAGE: payload.message,
|
|
67
|
+
OMP_NOTIFY_CWD: payload.cwd,
|
|
68
|
+
OMP_NOTIFY_TIMESTAMP: String(payload.timestamp),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
setEnvValue(env, "OMP_NOTIFY_SESSION_NAME", payload.session.name);
|
|
72
|
+
setEnvValue(env, "OMP_NOTIFY_SESSION_FILE", payload.session.file);
|
|
73
|
+
setEnvValue(env, "OMP_NOTIFY_TOOL_NAME", payload.toolName);
|
|
74
|
+
setEnvValue(env, "OMP_NOTIFY_QUESTION_COUNT", payload.ask?.questionCount);
|
|
75
|
+
|
|
76
|
+
return env;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildAskWaitingPayload(input: AskWaitingPayloadInput): NotificationPayload {
|
|
80
|
+
const questions = input.questions.map(cloneQuestion);
|
|
81
|
+
return {
|
|
82
|
+
event: "ask_waiting",
|
|
83
|
+
message: buildMessage("Waiting for input", input.sessionName),
|
|
84
|
+
timestamp: input.timestamp ?? Date.now(),
|
|
85
|
+
cwd: input.cwd,
|
|
86
|
+
session: buildSession(input.sessionName, input.sessionFile),
|
|
87
|
+
toolName: "ask",
|
|
88
|
+
ask: {
|
|
89
|
+
questionCount: questions.length,
|
|
90
|
+
questions,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildCompletionPayload(input: CompletionPayloadInput): NotificationPayload {
|
|
96
|
+
return {
|
|
97
|
+
event: "completion",
|
|
98
|
+
message: buildMessage("Complete", input.sessionName),
|
|
99
|
+
timestamp: input.timestamp ?? Date.now(),
|
|
100
|
+
cwd: input.cwd,
|
|
101
|
+
session: buildSession(input.sessionName, input.sessionFile),
|
|
102
|
+
};
|
|
103
|
+
}
|