claude-notification-plugin 1.1.30 → 1.1.34
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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -9
- package/bin/constants.js +3 -0
- package/bin/install.js +0 -1
- package/commit-sha +1 -1
- package/listener/LISTENER-DETAILED.md +34 -36
- package/listener/listener.js +6 -4
- package/listener/pty-runner.js +445 -0
- package/notifier/notifier.js +47 -23
- package/package.json +3 -2
- package/listener/task-runner.js +0 -198
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-notification-plugin",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.34",
|
|
4
4
|
"description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Viacheslav Makarov",
|
package/README.md
CHANGED
|
@@ -75,20 +75,20 @@ Config file: `~/.claude/claude-notify.config.json`
|
|
|
75
75
|
"enabled": true
|
|
76
76
|
},
|
|
77
77
|
"webhookUrl": "",
|
|
78
|
-
"sendUserPromptToWebhook": false,
|
|
79
78
|
"notifyAfterSeconds": 15,
|
|
80
79
|
"notifyOnWaiting": false,
|
|
81
80
|
"debug": false,
|
|
82
81
|
"listener": {
|
|
82
|
+
"claudeArgs": ["--permission-mode", "auto"],
|
|
83
83
|
"projects": {
|
|
84
84
|
"default": {
|
|
85
85
|
"path": "abs-path-to-project"
|
|
86
|
+
"claudeArgs": ["--permission-mode", "bypassPermissions"]
|
|
86
87
|
},
|
|
87
88
|
"alias1": {
|
|
88
89
|
"path": "abs-path-to-project"
|
|
89
90
|
}
|
|
90
91
|
},
|
|
91
|
-
"claudeArgs": ["--permission-mode", "auto"],
|
|
92
92
|
"continueSession": true,
|
|
93
93
|
"worktreeBaseDir": "abs-path-to-worktrees-root",
|
|
94
94
|
"autoCreateWorktree": true,
|
|
@@ -131,12 +131,9 @@ ENV: `CLAUDE_NOTIFY_VOICE`
|
|
|
131
131
|
**notifyOnWaiting** — Notify when Claude is waiting for input. Default: **false**
|
|
132
132
|
ENV: `CLAUDE_NOTIFY_WAITING`
|
|
133
133
|
|
|
134
|
-
**webhookUrl** — POST notification JSON to this URL.
|
|
134
|
+
**webhookUrl** — POST notification JSON to this URL. When set, all events (including user prompts) are sent. Set env to empty string (`""`) to disable per-project.
|
|
135
135
|
ENV: `CLAUDE_NOTIFY_WEBHOOK_URL`
|
|
136
136
|
|
|
137
|
-
**sendUserPromptToWebhook** — Also send user prompts to the webhook. Requires `webhookUrl`. Default: **false**
|
|
138
|
-
ENV: `CLAUDE_NOTIFY_SEND_USER_PROMPT_TO_WEBHOOK`
|
|
139
|
-
|
|
140
137
|
**notifyAfterSeconds** — Skip notifications for tasks shorter than this. Default: **15**
|
|
141
138
|
ENV: `CLAUDE_NOTIFY_AFTER_SECONDS`
|
|
142
139
|
|
|
@@ -163,7 +160,6 @@ Add to `.claude/settings.local.json` in the project root:
|
|
|
163
160
|
"CLAUDE_NOTIFY_DEBUG": 0,
|
|
164
161
|
"CLAUDE_NOTIFY_INCLUDE_LAST_CC_MESSAGE_IN_TELEGRAM": 1,
|
|
165
162
|
"CLAUDE_NOTIFY_WEBHOOK_URL": "",
|
|
166
|
-
"CLAUDE_NOTIFY_SEND_USER_PROMPT_TO_WEBHOOK": 0,
|
|
167
163
|
"CLAUDE_NOTIFY_AFTER_SECONDS": 15
|
|
168
164
|
}
|
|
169
165
|
}
|
|
@@ -171,7 +167,7 @@ Add to `.claude/settings.local.json` in the project root:
|
|
|
171
167
|
|
|
172
168
|
## Telegram Listener
|
|
173
169
|
|
|
174
|
-
Background daemon that receives tasks from Telegram and executes them via
|
|
170
|
+
Background daemon that receives tasks from Telegram and executes them via an interactive Claude Code PTY session. The result is sent back to Telegram.
|
|
175
171
|
|
|
176
172
|
The Listener uses the same bot and `chatId` as notifications.
|
|
177
173
|
|
|
@@ -259,7 +255,7 @@ All commands start with `/` and execute instantly (not queued).
|
|
|
259
255
|
| Parameter | Default | Description |
|
|
260
256
|
|----------------------|-----------------------|----------------------------------------------------------------------------------------|
|
|
261
257
|
| `projects` | (required) | Map of projects: `alias → { path }` |
|
|
262
|
-
| `claudeArgs` | `[]` | Extra CLI args for
|
|
258
|
+
| `claudeArgs` | `[]` | Extra CLI args for Claude (e.g. `["--permission-mode", "auto"]`) |
|
|
263
259
|
| `continueSession` | `true` | Continue previous session context (`--continue` flag). Claude remembers previous tasks |
|
|
264
260
|
| `worktreeBaseDir` | `~/.claude/worktrees` | Where auto-created worktrees are stored |
|
|
265
261
|
| `autoCreateWorktree` | `true` | Auto-create worktrees for unknown branches |
|
package/bin/constants.js
CHANGED
|
@@ -13,6 +13,9 @@ export const RESOLVER_FILENAME = 'claude-notify-resolve.js';
|
|
|
13
13
|
export const LISTENER_LOG_FILENAME = '.cc-n-listener.log';
|
|
14
14
|
export const INSTALL_LOG_FILENAME = 'claude-notify-install.log';
|
|
15
15
|
|
|
16
|
+
// PTY signal directory
|
|
17
|
+
export const PTY_SIGNAL_DIR = path.join(CLAUDE_DIR, 'pty-signals');
|
|
18
|
+
|
|
16
19
|
// Full paths
|
|
17
20
|
export const CONFIG_PATH = path.join(CLAUDE_DIR, CONFIG_FILENAME);
|
|
18
21
|
export const STATE_PATH = path.join(CLAUDE_DIR, STATE_FILENAME);
|
package/bin/install.js
CHANGED
package/commit-sha
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
f5fe014bea67b636f42de095623452d25a44b329
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Telegram Listener - Detailed Guide
|
|
2
2
|
|
|
3
3
|
Telegram Listener is a background daemon that receives tasks from a Telegram chat
|
|
4
|
-
and executes them on your machine via
|
|
4
|
+
and executes them on your machine via an interactive Claude Code PTY session. The result is sent back to Telegram.
|
|
5
5
|
|
|
6
6
|
**[Quick Start here](../LISTENER.md)**
|
|
7
7
|
|
|
@@ -178,11 +178,11 @@ Running two listeners is impossible — the PID file prevents it. And this is im
|
|
|
178
178
|
│ └───────┬────────┘ │ │
|
|
179
179
|
│ │ │ │
|
|
180
180
|
│ ┌───────┴────────┐ ┌───────┴───────┐ │
|
|
181
|
-
│ │ MessageParser │ │
|
|
181
|
+
│ │ MessageParser │ │ PtyRunner │ │
|
|
182
182
|
│ │ │ │ │ │
|
|
183
|
-
│ │ /proj/branch │ │
|
|
183
|
+
│ │ /proj/branch │ │ PTY session │ │
|
|
184
184
|
│ │ /commands │ │ timeouts │ │
|
|
185
|
-
│ └────────────────┘ │
|
|
185
|
+
│ └────────────────┘ │ signal files │ │
|
|
186
186
|
│ └───────────────┘ │
|
|
187
187
|
│ ┌────────────────┐ ┌───────────────┐ │
|
|
188
188
|
│ │WorktreeManager │ │ Logger │ │
|
|
@@ -199,7 +199,7 @@ Running two listeners is impossible — the PID file prevents it. And this is im
|
|
|
199
199
|
| **TelegramPoller** | `telegram-poller.js` | Long polling to the Telegram API. Receives messages, sends replies. Splits long messages into chunks |
|
|
200
200
|
| **MessageParser** | `message-parser.js` | Parses message text: is it a command (`/status`) or a task (`/proj1 fix bug`)? Extracts project, branch, task text |
|
|
201
201
|
| **WorkQueue** | `work-queue.js` | Manages task queues. Each working directory has a separate FIFO queue. Guarantees: one `claude` process per directory. Persists state to disk |
|
|
202
|
-
| **
|
|
202
|
+
| **PtyRunner** | `pty-runner.js` | Runs Claude in an interactive PTY session (via `node-pty`). Reuses sessions across tasks. Receives results via hook signal files. Monitors timeouts. Emits events: complete, error, timeout |
|
|
203
203
|
| **WorktreeManager** | `worktree-manager.js` | Creates and removes git worktrees. Auto-discovery via `git worktree list`. Maps `/project/branch` to a path on disk |
|
|
204
204
|
| **Logger** | `logger.js` | Writes operational log to `~/.claude/.cc-n-listener.log`. Rotation when exceeding 5 MB (old file → `.log.old`) |
|
|
205
205
|
| **TaskLogger** | `task-logger.js` | Writes task Q&A logs (questions to Claude and answers). Separate file per project/branch. Rotation at 5 MB |
|
|
@@ -255,8 +255,8 @@ WorkQueue.enqueue(workDir, task)
|
|
|
255
255
|
│
|
|
256
256
|
├─ workDir is free (active = null)
|
|
257
257
|
│ → active = task
|
|
258
|
-
│ →
|
|
259
|
-
│ →
|
|
258
|
+
│ → PtyRunner.run(workDir, task)
|
|
259
|
+
│ → sends task to Claude PTY session
|
|
260
260
|
│ → Telegram: "⏳ Running: fix bug"
|
|
261
261
|
│
|
|
262
262
|
└─ workDir is busy (active != null)
|
|
@@ -269,7 +269,7 @@ TaskRunner emit 'complete'/'error'/'timeout'
|
|
|
269
269
|
├─ Send result to Telegram
|
|
270
270
|
└─ WorkQueue.onTaskComplete(workDir)
|
|
271
271
|
├─ Queue is empty → active = null
|
|
272
|
-
└─ More tasks → shift() →
|
|
272
|
+
└─ More tasks → shift() → PtyRunner.run()
|
|
273
273
|
```
|
|
274
274
|
|
|
275
275
|
---
|
|
@@ -340,7 +340,7 @@ Full example of `~/.claude/claude-notify.config.json` with the listener section:
|
|
|
340
340
|
| Parameter | Default | Description |
|
|
341
341
|
|---|---|---|
|
|
342
342
|
| `projects` | — (required) | Map of projects: `alias → { path, worktrees?, claudeArgs? }` |
|
|
343
|
-
| `claudeArgs` | `[]` | Extra CLI args passed to
|
|
343
|
+
| `claudeArgs` | `[]` | Extra CLI args passed to Claude (e.g. `["--permission-mode", "auto"]`). Can also be set per-project to override |
|
|
344
344
|
| `continueSession` | `true` | Continue previous session context per workDir. Claude remembers previous tasks. Use `/newsession` or `/clear` to reset |
|
|
345
345
|
| `worktreeBaseDir` | `~/.claude/worktrees` | Where auto-created worktrees are stored |
|
|
346
346
|
| `autoCreateWorktree` | `true` | Automatically create a worktree if the branch is not found |
|
|
@@ -417,9 +417,9 @@ If the worktree doesn't exist, it will be created automatically.
|
|
|
417
417
|
2. Parses `/project/branch` from the beginning of the message
|
|
418
418
|
3. Determines the working directory (workDir)
|
|
419
419
|
4. Checks: is this workDir busy with another task?
|
|
420
|
-
- **No** →
|
|
420
|
+
- **No** → sends task to the PTY session immediately, replies with `⏳ Running...`
|
|
421
421
|
- **Yes** → adds to the queue, replies with `📋 Queued (position N)...`
|
|
422
|
-
5. When Claude finishes → sends the result to Telegram
|
|
422
|
+
5. When Claude finishes (hook signal file received) → sends the result to Telegram
|
|
423
423
|
6. If there's a next task in the queue → starts it
|
|
424
424
|
|
|
425
425
|
---
|
|
@@ -748,19 +748,20 @@ Shows a brief reference for all commands.
|
|
|
748
748
|
→ Yes: queue.push(task), reply with position
|
|
749
749
|
|
|
750
750
|
4. EXECUTION
|
|
751
|
-
|
|
751
|
+
Task sent to Claude PTY session
|
|
752
752
|
cwd = workDir
|
|
753
753
|
timeout = 30 min
|
|
754
754
|
Telegram: "⏳ Running: <task>"
|
|
755
755
|
|
|
756
756
|
5. WAITING
|
|
757
|
-
|
|
757
|
+
Claude is working in the PTY session...
|
|
758
758
|
(listener continues accepting other messages)
|
|
759
|
+
Hook "Stop" fires → signal file written
|
|
759
760
|
|
|
760
761
|
6. COMPLETION
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
762
|
+
Signal file received:
|
|
763
|
+
lastAssistantMessage → "✅ Done" + response text
|
|
764
|
+
PTY error/crash → "❌ Error"
|
|
764
765
|
timeout → "⏰ Timeout"
|
|
765
766
|
|
|
766
767
|
7. NEXT TASK
|
|
@@ -769,24 +770,22 @@ Shows a brief reference for all commands.
|
|
|
769
770
|
→ No: active = null, workDir is free
|
|
770
771
|
```
|
|
771
772
|
|
|
772
|
-
###
|
|
773
|
+
### How Claude runs
|
|
773
774
|
|
|
774
|
-
The
|
|
775
|
+
The listener spawns an interactive Claude Code session in a pseudo-terminal (PTY) using `node-pty`. This is equivalent to running `claude` in a real terminal — Claude has full access to all its capabilities (hooks, tools, interactive features).
|
|
775
776
|
|
|
776
|
-
|
|
777
|
-
claude -p "your message text from Telegram" --output-format text [claudeArgs...]
|
|
778
|
-
```
|
|
779
|
-
|
|
780
|
-
With the working directory (`cwd`) = project/worktree workDir.
|
|
777
|
+
The working directory (`cwd`) = project/worktree workDir.
|
|
781
778
|
|
|
782
779
|
Extra CLI arguments can be configured via `claudeArgs` in config (global or per-project).
|
|
783
|
-
Recommended: `["--permission-mode", "auto"]` — allows Claude to use tools (Edit, Bash, Read, etc.) without interactive prompts
|
|
780
|
+
Recommended: `["--permission-mode", "auto"]` — allows Claude to use tools (Edit, Bash, Read, etc.) without interactive prompts.
|
|
784
781
|
|
|
785
782
|
Claude sees the project files, CLAUDE.md, .claude/settings.json, and everything else as if you had launched it manually in that directory.
|
|
786
783
|
|
|
784
|
+
Task results are received via Claude's `Stop` hook, which writes a signal file containing `last_assistant_message` — the clean final response (not the raw PTY output with spinners and tool calls).
|
|
785
|
+
|
|
787
786
|
### Session continuity
|
|
788
787
|
|
|
789
|
-
When `continueSession` is enabled (default), the listener
|
|
788
|
+
When `continueSession` is enabled (default), the listener reuses the same PTY session for subsequent tasks in the same workDir. The Claude process stays alive between tasks, preserving full context — exactly like working in an interactive terminal.
|
|
790
789
|
|
|
791
790
|
Messages show session status:
|
|
792
791
|
- `🆕` = new session (first task or after `/newsession`/`/clear`)
|
|
@@ -799,7 +798,7 @@ Use `/newsession` to reset the session when context gets full, or `/clear` to re
|
|
|
799
798
|
|
|
800
799
|
### What is returned to Telegram
|
|
801
800
|
|
|
802
|
-
The `
|
|
801
|
+
The `last_assistant_message` from Claude's Stop hook — the clean final response to your task.
|
|
803
802
|
|
|
804
803
|
Handling long responses:
|
|
805
804
|
- Up to 4096 characters — a single message
|
|
@@ -880,13 +879,13 @@ The Listener processes **only** messages from the `chatId` specified in the conf
|
|
|
880
879
|
|
|
881
880
|
### No shell injection
|
|
882
881
|
|
|
883
|
-
Task text is
|
|
882
|
+
Task text is written to the PTY session's stdin, not passed through a shell command:
|
|
884
883
|
|
|
885
884
|
```js
|
|
886
|
-
//
|
|
887
|
-
|
|
885
|
+
// PTY session — text goes to Claude's interactive prompt:
|
|
886
|
+
ptyProcess.write(taskText + '\r')
|
|
888
887
|
|
|
889
|
-
// NOT
|
|
888
|
+
// NOT through shell interpolation:
|
|
890
889
|
exec(`claude -p "${userText}"`)
|
|
891
890
|
```
|
|
892
891
|
|
|
@@ -942,8 +941,7 @@ The watchdog will automatically clear stale tasks on the next startup.
|
|
|
942
941
|
|
|
943
942
|
### Claude gives low-quality responses (doesn't edit files, just describes what to do)
|
|
944
943
|
|
|
945
|
-
|
|
946
|
-
Add `claudeArgs` to your listener config:
|
|
944
|
+
Add `claudeArgs` to your listener config to grant tool permissions:
|
|
947
945
|
|
|
948
946
|
```json
|
|
949
947
|
"listener": {
|
|
@@ -1020,8 +1018,8 @@ You (terminal): claude-notify listener start
|
|
|
1020
1018
|
You: /api add endpoint GET /users with pagination
|
|
1021
1019
|
Bot: ⏳ [/api] Running: add endpoint GET /users with pagination
|
|
1022
1020
|
|
|
1023
|
-
Behind the scenes:
|
|
1024
|
-
claude
|
|
1021
|
+
Behind the scenes: PTY session created
|
|
1022
|
+
claude (interactive PTY) → task sent
|
|
1025
1023
|
cwd = /home/user/projects/api
|
|
1026
1024
|
|
|
1027
1025
|
=== 10:02 — Task to another project (in parallel!) ===
|
|
@@ -1029,7 +1027,7 @@ Bot: ⏳ [/api] Running: add endpoint GET /users with pagination
|
|
|
1029
1027
|
You: /web add a /users page that calls GET /users
|
|
1030
1028
|
Bot: ⏳ [/web] Running: add a /users page that calls GET /users
|
|
1031
1029
|
|
|
1032
|
-
Now two
|
|
1030
|
+
Now two PTY sessions are running in parallel:
|
|
1033
1031
|
one in /home/user/projects/api, another in /home/user/projects/web
|
|
1034
1032
|
|
|
1035
1033
|
=== 10:03 — Another task for api (queued) ===
|
|
@@ -1044,7 +1042,7 @@ You: /api/feature/auth add JWT authorization middleware
|
|
|
1044
1042
|
Bot: 🌿 Created worktree feature/auth for project "api"
|
|
1045
1043
|
⏳ [/api/feature/auth] Running: add JWT authorization middleware
|
|
1046
1044
|
|
|
1047
|
-
Three
|
|
1045
|
+
Three PTY sessions running in parallel:
|
|
1048
1046
|
1. api/main → GET /users
|
|
1049
1047
|
2. api/auth → JWT middleware
|
|
1050
1048
|
3. web/main → /users page
|
package/listener/listener.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createLogger } from './logger.js';
|
|
|
8
8
|
import { createTaskLogger } from './task-logger.js';
|
|
9
9
|
import { TelegramPoller, escapeHtml } from './telegram-poller.js';
|
|
10
10
|
import { WorkQueue } from './work-queue.js';
|
|
11
|
-
import {
|
|
11
|
+
import { PtyRunner } from './pty-runner.js';
|
|
12
12
|
import { WorktreeManager } from './worktree-manager.js';
|
|
13
13
|
import { parseMessage, parseTarget } from './message-parser.js';
|
|
14
14
|
import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME } from '../bin/constants.js';
|
|
@@ -99,7 +99,9 @@ const queue = new WorkQueue(
|
|
|
99
99
|
const taskLogDir = config.listener?.taskLogDir || listenerLogDir;
|
|
100
100
|
fs.mkdirSync(taskLogDir, { recursive: true });
|
|
101
101
|
const taskLogger = createTaskLogger(taskLogDir);
|
|
102
|
-
|
|
102
|
+
|
|
103
|
+
const runner = new PtyRunner(logger, taskTimeout, taskLogger);
|
|
104
|
+
|
|
103
105
|
const worktreeManager = new WorktreeManager(config, logger);
|
|
104
106
|
|
|
105
107
|
const startTime = Date.now();
|
|
@@ -308,8 +310,8 @@ async function startTask (workDir, task) {
|
|
|
308
310
|
task.runningMessageId = runningMsgId;
|
|
309
311
|
const claudeArgs = getClaudeArgs(entry?.project);
|
|
310
312
|
try {
|
|
311
|
-
|
|
312
|
-
queue.markStarted(workDir,
|
|
313
|
+
runner.run(workDir, task, claudeArgs, continueSession);
|
|
314
|
+
queue.markStarted(workDir, task.pid || 0);
|
|
313
315
|
} catch (err) {
|
|
314
316
|
logger.error(`Failed to start task: ${err.message}`);
|
|
315
317
|
poller.sendMessage(`❌ <code>${label}</code>\nFailed to start: ${escapeHtml(err.message)}`);
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
import { PTY_SIGNAL_DIR } from '../bin/constants.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT = 600_000; // 10 minutes
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PTY-based runner for Claude Code.
|
|
12
|
+
* Uses node-pty to spawn an interactive Claude session and
|
|
13
|
+
* receives completion signals via marker files written by the notifier hook.
|
|
14
|
+
*/
|
|
15
|
+
export class PtyRunner extends EventEmitter {
|
|
16
|
+
constructor (logger, timeout, taskLogger) {
|
|
17
|
+
super();
|
|
18
|
+
this.logger = logger;
|
|
19
|
+
this.timeout = timeout || DEFAULT_TIMEOUT;
|
|
20
|
+
this.taskLogger = taskLogger || null;
|
|
21
|
+
// workDir -> { pty, state, currentTask, sessionId, workDir, _pendingId, _buffer }
|
|
22
|
+
this.sessions = new Map();
|
|
23
|
+
this.pendingMarkers = new Map(); // pendingId -> resolve callback
|
|
24
|
+
this._pty = null; // lazy-loaded node-pty module
|
|
25
|
+
this._startMarkerWatcher();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lazily load node-pty module.
|
|
30
|
+
*/
|
|
31
|
+
async _loadPty () {
|
|
32
|
+
if (!this._pty) {
|
|
33
|
+
this._pty = await import('node-pty');
|
|
34
|
+
}
|
|
35
|
+
return this._pty;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start watching the signal directory for marker files.
|
|
40
|
+
*/
|
|
41
|
+
_startMarkerWatcher () {
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(PTY_SIGNAL_DIR, { recursive: true });
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clean up stale marker files on startup
|
|
49
|
+
try {
|
|
50
|
+
const files = fs.readdirSync(PTY_SIGNAL_DIR);
|
|
51
|
+
for (const f of files) {
|
|
52
|
+
if (f.endsWith('.json')) {
|
|
53
|
+
try {
|
|
54
|
+
fs.unlinkSync(path.join(PTY_SIGNAL_DIR, f));
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Use polling-based watcher for cross-platform reliability
|
|
65
|
+
this._pollInterval = setInterval(() => this._checkMarkerFiles(), 500);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check for new marker files in the signal directory.
|
|
70
|
+
*/
|
|
71
|
+
_checkMarkerFiles () {
|
|
72
|
+
if (this.pendingMarkers.size === 0) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let files;
|
|
77
|
+
try {
|
|
78
|
+
files = fs.readdirSync(PTY_SIGNAL_DIR);
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
if (!f.endsWith('.json')) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const filePath = path.join(PTY_SIGNAL_DIR, f);
|
|
89
|
+
let marker;
|
|
90
|
+
try {
|
|
91
|
+
marker = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Try to match by cwd (primary matching for PTY runner)
|
|
97
|
+
const cwd = marker.cwd;
|
|
98
|
+
if (cwd) {
|
|
99
|
+
for (const [pid, resolve] of this.pendingMarkers) {
|
|
100
|
+
const session = this._findSessionByPendingId(pid);
|
|
101
|
+
if (session && this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
|
|
102
|
+
this.pendingMarkers.delete(pid);
|
|
103
|
+
try {
|
|
104
|
+
fs.unlinkSync(filePath);
|
|
105
|
+
} catch {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
resolve(marker);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_normalizePath (p) {
|
|
117
|
+
return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_findSessionByPendingId (pendingId) {
|
|
121
|
+
for (const [, session] of this.sessions) {
|
|
122
|
+
if (session._pendingId === pendingId) {
|
|
123
|
+
return session;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wait for a marker file for the given pending ID.
|
|
131
|
+
*/
|
|
132
|
+
_waitForMarker (pendingId, timeoutMs) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
this.pendingMarkers.delete(pendingId);
|
|
136
|
+
reject(new Error('Marker timeout'));
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
|
|
139
|
+
this.pendingMarkers.set(pendingId, (marker) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
resolve(marker);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run a task in a specific workDir.
|
|
148
|
+
* Returns the task object immediately, emits events on completion.
|
|
149
|
+
* Returns the task object immediately, emits events on completion.
|
|
150
|
+
*/
|
|
151
|
+
run (workDir, task, claudeArgs = [], continueSession = false) {
|
|
152
|
+
if (this.sessions.has(workDir) && this.sessions.get(workDir).state === 'busy') {
|
|
153
|
+
throw new Error(`Already running a task in ${workDir}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (this.taskLogger) {
|
|
157
|
+
this.taskLogger.logQuestion(task.project || 'unknown', task.branch || 'main', workDir, task.text);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
task.startedAt = new Date().toISOString();
|
|
161
|
+
task.continueSession = continueSession;
|
|
162
|
+
|
|
163
|
+
// Mark as busy immediately with a placeholder session
|
|
164
|
+
const existingSession = this.sessions.get(workDir);
|
|
165
|
+
if (existingSession && existingSession.state === 'idle' && continueSession) {
|
|
166
|
+
// Reuse existing PTY session
|
|
167
|
+
this._sendTask(workDir, existingSession, task);
|
|
168
|
+
} else {
|
|
169
|
+
// Need a new PTY session — create async
|
|
170
|
+
if (existingSession) {
|
|
171
|
+
this._destroyPty(workDir);
|
|
172
|
+
}
|
|
173
|
+
// Create a placeholder to prevent double-starts
|
|
174
|
+
this.sessions.set(workDir, { state: 'busy', currentTask: task, workDir });
|
|
175
|
+
this._createAndSendTask(workDir, task, claudeArgs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return task;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create PTY session and send task (async, fire-and-forget).
|
|
183
|
+
*/
|
|
184
|
+
_createAndSendTask (workDir, task, claudeArgs) {
|
|
185
|
+
this._createPtySession(workDir, claudeArgs).then((session) => {
|
|
186
|
+
this.sessions.set(workDir, session);
|
|
187
|
+
this._sendTask(workDir, session, task);
|
|
188
|
+
}).catch((err) => {
|
|
189
|
+
this.sessions.delete(workDir);
|
|
190
|
+
const errorMsg = `Failed to create PTY session: ${err.message}`;
|
|
191
|
+
this.logger.error(errorMsg);
|
|
192
|
+
if (this.taskLogger) {
|
|
193
|
+
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', errorMsg, 1);
|
|
194
|
+
}
|
|
195
|
+
this.emit('error', workDir, task, errorMsg);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Send a task to an existing PTY session and wait for completion.
|
|
201
|
+
*/
|
|
202
|
+
_sendTask (workDir, session, task) {
|
|
203
|
+
const pendingId = `pty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
204
|
+
|
|
205
|
+
session.state = 'busy';
|
|
206
|
+
session.currentTask = task;
|
|
207
|
+
session._pendingId = pendingId;
|
|
208
|
+
|
|
209
|
+
// Set up marker wait + timeout
|
|
210
|
+
const markerPromise = this._waitForMarker(pendingId, this.timeout);
|
|
211
|
+
|
|
212
|
+
// Send the task text to the PTY
|
|
213
|
+
session.pty.write(task.text + '\r');
|
|
214
|
+
this.logger.info(`PTY task sent to ${workDir}: ${task.text.slice(0, 100)}`);
|
|
215
|
+
|
|
216
|
+
// Handle completion asynchronously
|
|
217
|
+
markerPromise.then((marker) => {
|
|
218
|
+
session.state = 'idle';
|
|
219
|
+
session.currentTask = null;
|
|
220
|
+
session.sessionId = marker.sessionId;
|
|
221
|
+
|
|
222
|
+
const result = {
|
|
223
|
+
text: marker.lastAssistantMessage || '',
|
|
224
|
+
sessionId: marker.sessionId || null,
|
|
225
|
+
cost: marker.cost || 0,
|
|
226
|
+
numTurns: marker.numTurns || 0,
|
|
227
|
+
durationMs: marker.durationMs || 0,
|
|
228
|
+
contextWindow: marker.contextWindow || 0,
|
|
229
|
+
totalTokens: marker.totalTokens || 0,
|
|
230
|
+
isError: false,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
this.logger.info(`PTY task completed in ${workDir} (session: ${result.sessionId || 'unknown'})`);
|
|
234
|
+
if (this.taskLogger) {
|
|
235
|
+
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', result.text, 0);
|
|
236
|
+
}
|
|
237
|
+
this.emit('complete', workDir, task, result);
|
|
238
|
+
}).catch((err) => {
|
|
239
|
+
session.state = 'idle';
|
|
240
|
+
session.currentTask = null;
|
|
241
|
+
|
|
242
|
+
if (err.message === 'Marker timeout') {
|
|
243
|
+
this.logger.warn(`PTY task timed out in ${workDir}`);
|
|
244
|
+
this._destroyPty(workDir);
|
|
245
|
+
this.emit('timeout', workDir, task);
|
|
246
|
+
} else {
|
|
247
|
+
this.logger.error(`PTY task error in ${workDir}: ${err.message}`);
|
|
248
|
+
if (this.taskLogger) {
|
|
249
|
+
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', err.message, 1);
|
|
250
|
+
}
|
|
251
|
+
this.emit('error', workDir, task, err.message);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Create a new PTY session for a workDir.
|
|
258
|
+
*/
|
|
259
|
+
async _createPtySession (workDir, claudeArgs = []) {
|
|
260
|
+
const pty = await this._loadPty();
|
|
261
|
+
const spawn = pty.spawn || pty.default?.spawn;
|
|
262
|
+
|
|
263
|
+
if (!spawn) {
|
|
264
|
+
throw new Error('node-pty spawn function not found');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Filter out pipe-mode-specific args
|
|
268
|
+
const args = claudeArgs.filter(a => a !== '-p' && a !== '--output-format' && a !== 'json');
|
|
269
|
+
|
|
270
|
+
this.logger.info(`Creating PTY session in ${workDir} with args: ${JSON.stringify(args)}`);
|
|
271
|
+
|
|
272
|
+
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
|
|
273
|
+
const shellArgs = process.platform === 'win32'
|
|
274
|
+
? ['/c', 'claude', ...args]
|
|
275
|
+
: ['-c', ['claude', ...args].join(' ')];
|
|
276
|
+
|
|
277
|
+
const ptyProcess = spawn(shell, shellArgs, {
|
|
278
|
+
name: 'xterm-256color',
|
|
279
|
+
cols: 120,
|
|
280
|
+
rows: 40,
|
|
281
|
+
cwd: workDir,
|
|
282
|
+
env: {
|
|
283
|
+
...process.env,
|
|
284
|
+
CLAUDE_NOTIFY_FROM_LISTENER: '1',
|
|
285
|
+
TERM: 'xterm-256color',
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const session = {
|
|
290
|
+
pty: ptyProcess,
|
|
291
|
+
state: 'starting',
|
|
292
|
+
currentTask: null,
|
|
293
|
+
sessionId: null,
|
|
294
|
+
workDir,
|
|
295
|
+
_pendingId: null,
|
|
296
|
+
_buffer: '',
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
ptyProcess.onData((data) => {
|
|
300
|
+
session._buffer += data;
|
|
301
|
+
// Keep buffer reasonable size
|
|
302
|
+
if (session._buffer.length > 50000) {
|
|
303
|
+
session._buffer = session._buffer.slice(-25000);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
308
|
+
this.logger.info(`PTY exited in ${workDir} with code ${exitCode}`);
|
|
309
|
+
const currentSession = this.sessions.get(workDir);
|
|
310
|
+
if (currentSession === session) {
|
|
311
|
+
if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
|
|
312
|
+
this.pendingMarkers.delete(session._pendingId);
|
|
313
|
+
if (session.currentTask) {
|
|
314
|
+
const task = session.currentTask;
|
|
315
|
+
session.state = 'dead';
|
|
316
|
+
session.currentTask = null;
|
|
317
|
+
this.sessions.delete(workDir);
|
|
318
|
+
const errorMsg = `PTY process exited unexpectedly (code ${exitCode})`;
|
|
319
|
+
this.logger.error(errorMsg);
|
|
320
|
+
if (this.taskLogger) {
|
|
321
|
+
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', errorMsg, exitCode || 1);
|
|
322
|
+
}
|
|
323
|
+
this.emit('error', workDir, task, errorMsg);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
this.sessions.delete(workDir);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Wait for Claude to start up (stabilize output)
|
|
332
|
+
await this._waitForReady(session, 15000);
|
|
333
|
+
|
|
334
|
+
session.state = 'idle';
|
|
335
|
+
this.logger.info(`PTY session ready in ${workDir}`);
|
|
336
|
+
return session;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Wait for PTY output to stabilize (Claude has loaded).
|
|
341
|
+
*/
|
|
342
|
+
_waitForReady (session, timeoutMs) {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
let lastLength = 0;
|
|
345
|
+
let stableCount = 0;
|
|
346
|
+
const checkInterval = 500;
|
|
347
|
+
|
|
348
|
+
const timer = setInterval(() => {
|
|
349
|
+
const currentLength = session._buffer.length;
|
|
350
|
+
if (currentLength > 0 && currentLength === lastLength) {
|
|
351
|
+
stableCount++;
|
|
352
|
+
if (stableCount >= 3) {
|
|
353
|
+
clearInterval(timer);
|
|
354
|
+
clearTimeout(timeout);
|
|
355
|
+
resolve();
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
stableCount = 0;
|
|
359
|
+
}
|
|
360
|
+
lastLength = currentLength;
|
|
361
|
+
}, checkInterval);
|
|
362
|
+
|
|
363
|
+
const timeout = setTimeout(() => {
|
|
364
|
+
clearInterval(timer);
|
|
365
|
+
resolve();
|
|
366
|
+
}, timeoutMs);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Destroy PTY process for a workDir.
|
|
372
|
+
*/
|
|
373
|
+
_destroyPty (workDir) {
|
|
374
|
+
const session = this.sessions.get(workDir);
|
|
375
|
+
if (!session) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
|
|
380
|
+
this.pendingMarkers.delete(session._pendingId);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
if (session.pty) {
|
|
385
|
+
session.pty.kill();
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
// already dead
|
|
389
|
+
}
|
|
390
|
+
this.sessions.delete(workDir);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Cancel the active task in a workDir.
|
|
395
|
+
*/
|
|
396
|
+
cancel (workDir) {
|
|
397
|
+
const session = this.sessions.get(workDir);
|
|
398
|
+
if (!session) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
if (session.pty) {
|
|
404
|
+
session.pty.write('\x03');
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
// ignore
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (session._pendingId && this.pendingMarkers.has(session._pendingId)) {
|
|
411
|
+
this.pendingMarkers.delete(session._pendingId);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
this._destroyPty(workDir);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Check if a task is running in a workDir.
|
|
419
|
+
*/
|
|
420
|
+
isRunning (workDir) {
|
|
421
|
+
const session = this.sessions.get(workDir);
|
|
422
|
+
return session?.state === 'busy';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Get active task info for a workDir.
|
|
427
|
+
*/
|
|
428
|
+
getActive (workDir) {
|
|
429
|
+
const session = this.sessions.get(workDir);
|
|
430
|
+
return session?.currentTask || null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Cancel all active tasks (for graceful shutdown).
|
|
435
|
+
*/
|
|
436
|
+
cancelAll () {
|
|
437
|
+
for (const workDir of [...this.sessions.keys()]) {
|
|
438
|
+
this._destroyPty(workDir);
|
|
439
|
+
}
|
|
440
|
+
if (this._pollInterval) {
|
|
441
|
+
clearInterval(this._pollInterval);
|
|
442
|
+
this._pollInterval = null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
package/notifier/notifier.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import process from 'process';
|
|
6
6
|
import { execSync, spawn } from 'child_process';
|
|
7
|
-
import { CONFIG_PATH, STATE_PATH } from '../bin/constants.js';
|
|
7
|
+
import { CONFIG_PATH, STATE_PATH, PTY_SIGNAL_DIR } from '../bin/constants.js';
|
|
8
8
|
|
|
9
9
|
// ----------------------
|
|
10
10
|
// CONFIG
|
|
@@ -61,7 +61,6 @@ function loadConfig () {
|
|
|
61
61
|
enabled: true,
|
|
62
62
|
},
|
|
63
63
|
webhookUrl: '',
|
|
64
|
-
sendUserPromptToWebhook: false,
|
|
65
64
|
notifyAfterSeconds: 15,
|
|
66
65
|
notifyOnWaiting: false,
|
|
67
66
|
debug: false,
|
|
@@ -95,9 +94,6 @@ function loadConfig () {
|
|
|
95
94
|
if (typeof user.webhookUrl === 'string') {
|
|
96
95
|
config.webhookUrl = user.webhookUrl;
|
|
97
96
|
}
|
|
98
|
-
if (typeof user.sendUserPromptToWebhook === 'boolean') {
|
|
99
|
-
config.sendUserPromptToWebhook = user.sendUserPromptToWebhook;
|
|
100
|
-
}
|
|
101
97
|
} catch {
|
|
102
98
|
// ignore malformed config
|
|
103
99
|
}
|
|
@@ -132,12 +128,9 @@ function loadConfig () {
|
|
|
132
128
|
if (process.env.CLAUDE_NOTIFY_INCLUDE_LAST_CC_MESSAGE_IN_TELEGRAM !== undefined) {
|
|
133
129
|
config.telegram.includeLastCcMessageInTelegram = process.env.CLAUDE_NOTIFY_INCLUDE_LAST_CC_MESSAGE_IN_TELEGRAM === '1';
|
|
134
130
|
}
|
|
135
|
-
if (process.env.CLAUDE_NOTIFY_WEBHOOK_URL) {
|
|
131
|
+
if (process.env.CLAUDE_NOTIFY_WEBHOOK_URL !== undefined) {
|
|
136
132
|
config.webhookUrl = process.env.CLAUDE_NOTIFY_WEBHOOK_URL;
|
|
137
133
|
}
|
|
138
|
-
if (process.env.CLAUDE_NOTIFY_SEND_USER_PROMPT_TO_WEBHOOK !== undefined) {
|
|
139
|
-
config.sendUserPromptToWebhook = process.env.CLAUDE_NOTIFY_SEND_USER_PROMPT_TO_WEBHOOK === '1';
|
|
140
|
-
}
|
|
141
134
|
if (process.env.CLAUDE_NOTIFY_AFTER_SECONDS !== undefined) {
|
|
142
135
|
const val = Number(process.env.CLAUDE_NOTIFY_AFTER_SECONDS);
|
|
143
136
|
if (!Number.isNaN(val)) {
|
|
@@ -158,9 +151,31 @@ function isNotifierDisabled () {
|
|
|
158
151
|
return true;
|
|
159
152
|
}
|
|
160
153
|
// Skip notifications for listener-spawned tasks unless explicitly enabled
|
|
161
|
-
|
|
162
|
-
&& process.env.CLAUDE_NOTIFY_AFTER_LISTENER !== '1'
|
|
154
|
+
if (process.env.CLAUDE_NOTIFY_FROM_LISTENER === '1'
|
|
155
|
+
&& process.env.CLAUDE_NOTIFY_AFTER_LISTENER !== '1') {
|
|
156
|
+
return 'listener-only';
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
163
160
|
|
|
161
|
+
function writePtySignalFile (event) {
|
|
162
|
+
const sessionId = event.session_id || 'unknown';
|
|
163
|
+
const cwd = event.cwd || process.cwd();
|
|
164
|
+
try {
|
|
165
|
+
fs.mkdirSync(PTY_SIGNAL_DIR, { recursive: true });
|
|
166
|
+
const signalFile = path.join(PTY_SIGNAL_DIR, `${sessionId}.json`);
|
|
167
|
+
fs.writeFileSync(signalFile, JSON.stringify({
|
|
168
|
+
sessionId,
|
|
169
|
+
cwd,
|
|
170
|
+
lastAssistantMessage: event.last_assistant_message || '',
|
|
171
|
+
cost: event.total_cost_usd || 0,
|
|
172
|
+
numTurns: event.num_turns || 0,
|
|
173
|
+
durationMs: event.duration_ms || 0,
|
|
174
|
+
timestamp: Date.now(),
|
|
175
|
+
}));
|
|
176
|
+
} catch {
|
|
177
|
+
// silent fail
|
|
178
|
+
}
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
// ----------------------
|
|
@@ -280,7 +295,9 @@ async function sendTelegram (config, state) {
|
|
|
280
295
|
body: JSON.stringify(body),
|
|
281
296
|
});
|
|
282
297
|
const data = await res.json();
|
|
283
|
-
if (!data.ok)
|
|
298
|
+
if (!data.ok) {
|
|
299
|
+
console.error('[telegram] HTML send failed:', JSON.stringify(data));
|
|
300
|
+
}
|
|
284
301
|
if (data.ok && data.result?.message_id) {
|
|
285
302
|
if (!state.sentMessages) {
|
|
286
303
|
state.sentMessages = [];
|
|
@@ -637,7 +654,16 @@ process.stdin.on('end', async () => {
|
|
|
637
654
|
const project = path.basename(cwd);
|
|
638
655
|
const sessionId = event.session_id || 'default';
|
|
639
656
|
|
|
640
|
-
|
|
657
|
+
const disabled = isNotifierDisabled();
|
|
658
|
+
if (disabled === true) {
|
|
659
|
+
process.exit(0);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// For listener-only mode: write PTY signal file on Stop, then exit
|
|
663
|
+
if (disabled === 'listener-only') {
|
|
664
|
+
if (eventType === 'Stop') {
|
|
665
|
+
writePtySignalFile(event);
|
|
666
|
+
}
|
|
641
667
|
process.exit(0);
|
|
642
668
|
}
|
|
643
669
|
|
|
@@ -651,15 +677,13 @@ process.stdin.on('end', async () => {
|
|
|
651
677
|
if (eventType === 'UserPromptSubmit') {
|
|
652
678
|
state.sessions[sessionId] = { start: Date.now() };
|
|
653
679
|
saveState(state);
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
});
|
|
662
|
-
}
|
|
680
|
+
await sendWebhook(config, {
|
|
681
|
+
title: 'User prompt submitted',
|
|
682
|
+
project,
|
|
683
|
+
trigger: eventType,
|
|
684
|
+
prompt: event.prompt || '',
|
|
685
|
+
hookEvent: event,
|
|
686
|
+
});
|
|
663
687
|
process.exit(0);
|
|
664
688
|
}
|
|
665
689
|
|
|
@@ -695,7 +719,7 @@ process.stdin.on('end', async () => {
|
|
|
695
719
|
label += `/${branch}`;
|
|
696
720
|
labelHtml += `/${escapeHtml(branch)}`;
|
|
697
721
|
}
|
|
698
|
-
labelHtml = `<code>${labelHtml}</code
|
|
722
|
+
labelHtml = `<code>${labelHtml}</code>`;
|
|
699
723
|
const triggerLine = config.debug ? `\nTrigger: ${eventType}` : '';
|
|
700
724
|
|
|
701
725
|
const desktopTitle = label;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-notification-plugin",
|
|
3
3
|
"productName": "claude-notification-plugin",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.34",
|
|
5
5
|
"description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"access": "public"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"node-notifier": "^10.0.1"
|
|
54
|
+
"node-notifier": "^10.0.1",
|
|
55
|
+
"node-pty": "^1.1.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
58
|
"eslint-plugin-import": "^2.31.0",
|
package/listener/task-runner.js
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { spawn } from 'child_process';
|
|
4
|
-
import { EventEmitter } from 'events';
|
|
5
|
-
|
|
6
|
-
const DEFAULT_TIMEOUT = 600_000; // 10 minutes
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Parse JSON output from claude --output-format json.
|
|
10
|
-
* Returns structured result or fallback with raw text.
|
|
11
|
-
*/
|
|
12
|
-
function parseClaudeOutput (raw) {
|
|
13
|
-
try {
|
|
14
|
-
const data = JSON.parse(raw);
|
|
15
|
-
const modelUsage = data.modelUsage || {};
|
|
16
|
-
const model = Object.keys(modelUsage)[0];
|
|
17
|
-
const mu = model ? modelUsage[model] : {};
|
|
18
|
-
const totalTokens = (mu.inputTokens || 0)
|
|
19
|
-
+ (mu.cacheReadInputTokens || 0)
|
|
20
|
-
+ (mu.cacheCreationInputTokens || 0)
|
|
21
|
-
+ (mu.outputTokens || 0);
|
|
22
|
-
return {
|
|
23
|
-
text: data.result || '',
|
|
24
|
-
sessionId: data.session_id || null,
|
|
25
|
-
cost: data.total_cost_usd || 0,
|
|
26
|
-
numTurns: data.num_turns || 0,
|
|
27
|
-
durationMs: data.duration_ms || 0,
|
|
28
|
-
contextWindow: mu.contextWindow || 0,
|
|
29
|
-
totalTokens,
|
|
30
|
-
isError: !!data.is_error,
|
|
31
|
-
};
|
|
32
|
-
} catch {
|
|
33
|
-
return { text: raw.trim(), sessionId: null };
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Runs claude CLI tasks and emits events on completion.
|
|
39
|
-
*/
|
|
40
|
-
export class TaskRunner extends EventEmitter {
|
|
41
|
-
constructor (logger, timeout, taskLogger) {
|
|
42
|
-
super();
|
|
43
|
-
this.logger = logger;
|
|
44
|
-
this.timeout = timeout || DEFAULT_TIMEOUT;
|
|
45
|
-
this.taskLogger = taskLogger || null;
|
|
46
|
-
this.activeProcesses = new Map(); // workDir -> { child, timer, task }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Run a task in a specific workDir.
|
|
51
|
-
* @param {string} workDir - Working directory
|
|
52
|
-
* @param {object} task - Task object { id, text, telegramMessageId, ... }
|
|
53
|
-
* @param {string[]} claudeArgs - Extra CLI args
|
|
54
|
-
* @param {boolean} continueSession - Add --continue flag
|
|
55
|
-
* @returns {object} task with pid
|
|
56
|
-
*/
|
|
57
|
-
run (workDir, task, claudeArgs = [], continueSession = false) {
|
|
58
|
-
if (this.activeProcesses.has(workDir)) {
|
|
59
|
-
throw new Error(`Already running a task in ${workDir}`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (this.taskLogger) {
|
|
63
|
-
this.taskLogger.logQuestion(task.project || 'unknown', task.branch || 'main', workDir, task.text);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const args = ['-p', task.text, '--output-format', 'json', ...claudeArgs];
|
|
67
|
-
if (continueSession) {
|
|
68
|
-
args.push('--continue');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const cmdLine = ['claude', ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
|
|
72
|
-
this.logger.info(`cwd: ${workDir}\n cmd: ${cmdLine}`);
|
|
73
|
-
|
|
74
|
-
const child = spawn('claude', args, {
|
|
75
|
-
cwd: workDir,
|
|
76
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
77
|
-
windowsHide: true,
|
|
78
|
-
shell: process.platform === 'win32',
|
|
79
|
-
env: { ...process.env, CLAUDE_NOTIFY_FROM_LISTENER: '1' },
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
let stdout = '';
|
|
83
|
-
let stderr = '';
|
|
84
|
-
|
|
85
|
-
child.stdout.on('data', (chunk) => {
|
|
86
|
-
stdout += chunk.toString();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
child.stderr.on('data', (chunk) => {
|
|
90
|
-
stderr += chunk.toString();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const timer = setTimeout(() => {
|
|
94
|
-
this.logger.warn(`Task "${task.id}" timed out in ${workDir}`);
|
|
95
|
-
this._killProcess(workDir);
|
|
96
|
-
this.emit('timeout', workDir, task);
|
|
97
|
-
}, this.timeout);
|
|
98
|
-
|
|
99
|
-
child.on('close', (code) => {
|
|
100
|
-
clearTimeout(timer);
|
|
101
|
-
this.activeProcesses.delete(workDir);
|
|
102
|
-
|
|
103
|
-
if (code === null) {
|
|
104
|
-
// Process was killed (timeout or cancel)
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (code === 0) {
|
|
109
|
-
const result = parseClaudeOutput(stdout);
|
|
110
|
-
this.logger.info(`Task "${task.id}" completed in ${workDir} (session: ${result.sessionId || 'unknown'})`);
|
|
111
|
-
if (this.taskLogger) {
|
|
112
|
-
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', result.text, 0);
|
|
113
|
-
}
|
|
114
|
-
this.emit('complete', workDir, task, result);
|
|
115
|
-
} else {
|
|
116
|
-
const errorMsg = stderr.trim() || `Process exited with code ${code}`;
|
|
117
|
-
this.logger.error(`Task "${task.id}" failed in ${workDir}: ${errorMsg}`);
|
|
118
|
-
if (this.taskLogger) {
|
|
119
|
-
this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', errorMsg, code);
|
|
120
|
-
}
|
|
121
|
-
this.emit('error', workDir, task, errorMsg);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
child.on('error', (err) => {
|
|
126
|
-
clearTimeout(timer);
|
|
127
|
-
this.activeProcesses.delete(workDir);
|
|
128
|
-
this.logger.error(`Task "${task.id}" spawn error: ${err.message}`);
|
|
129
|
-
this.emit('error', workDir, task, err.message);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
task.pid = child.pid;
|
|
133
|
-
task.startedAt = new Date().toISOString();
|
|
134
|
-
task.continueSession = continueSession;
|
|
135
|
-
this.activeProcesses.set(workDir, { child, timer, task });
|
|
136
|
-
|
|
137
|
-
return task;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Cancel the active task in a workDir.
|
|
142
|
-
*/
|
|
143
|
-
cancel (workDir) {
|
|
144
|
-
this._killProcess(workDir);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Check if a task is running in a workDir.
|
|
149
|
-
*/
|
|
150
|
-
isRunning (workDir) {
|
|
151
|
-
return this.activeProcesses.has(workDir);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Get active task info for a workDir.
|
|
156
|
-
*/
|
|
157
|
-
getActive (workDir) {
|
|
158
|
-
const entry = this.activeProcesses.get(workDir);
|
|
159
|
-
return entry?.task || null;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Cancel all active tasks (for graceful shutdown).
|
|
164
|
-
*/
|
|
165
|
-
cancelAll () {
|
|
166
|
-
for (const workDir of this.activeProcesses.keys()) {
|
|
167
|
-
this._killProcess(workDir);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
_killProcess (workDir) {
|
|
172
|
-
const entry = this.activeProcesses.get(workDir);
|
|
173
|
-
if (!entry) {
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
clearTimeout(entry.timer);
|
|
177
|
-
try {
|
|
178
|
-
if (process.platform === 'win32') {
|
|
179
|
-
spawn('taskkill', ['/PID', String(entry.child.pid), '/T', '/F'], {
|
|
180
|
-
stdio: 'ignore',
|
|
181
|
-
windowsHide: true,
|
|
182
|
-
});
|
|
183
|
-
} else {
|
|
184
|
-
entry.child.kill('SIGTERM');
|
|
185
|
-
setTimeout(() => {
|
|
186
|
-
try {
|
|
187
|
-
entry.child.kill('SIGKILL');
|
|
188
|
-
} catch {
|
|
189
|
-
// already dead
|
|
190
|
-
}
|
|
191
|
-
}, 3000);
|
|
192
|
-
}
|
|
193
|
-
} catch {
|
|
194
|
-
// process already dead
|
|
195
|
-
}
|
|
196
|
-
this.activeProcesses.delete(workDir);
|
|
197
|
-
}
|
|
198
|
-
}
|