claude-notification-plugin 1.1.29 → 1.1.32

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-notification-plugin",
3
- "version": "1.1.29",
3
+ "version": "1.1.32",
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
@@ -171,7 +171,7 @@ Add to `.claude/settings.local.json` in the project root:
171
171
 
172
172
  ## Telegram Listener
173
173
 
174
- Background daemon that receives tasks from Telegram and executes them via `claude -p`. The result is sent back to Telegram.
174
+ 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
175
 
176
176
  The Listener uses the same bot and `chatId` as notifications.
177
177
 
@@ -259,7 +259,7 @@ All commands start with `/` and execute instantly (not queued).
259
259
  | Parameter | Default | Description |
260
260
  |----------------------|-----------------------|----------------------------------------------------------------------------------------|
261
261
  | `projects` | (required) | Map of projects: `alias → { path }` |
262
- | `claudeArgs` | `[]` | Extra CLI args for `claude -p` (e.g. `["--permission-mode", "auto"]`) |
262
+ | `claudeArgs` | `[]` | Extra CLI args for Claude (e.g. `["--permission-mode", "auto"]`) |
263
263
  | `continueSession` | `true` | Continue previous session context (`--continue` flag). Claude remembers previous tasks |
264
264
  | `worktreeBaseDir` | `~/.claude/worktrees` | Where auto-created worktrees are stored |
265
265
  | `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/commit-sha CHANGED
@@ -1 +1 @@
1
- f96269c4c26426738161143aed8e1efbf7c86bac
1
+ b65d01cebc9e993e0a3808bcd3715fde0acfa7b4
@@ -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 `claude -p`. The result is sent back to Telegram.
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 │ │ TaskRunner │ │
181
+ │ │ MessageParser │ │ PtyRunner │ │
182
182
  │ │ │ │ │ │
183
- │ │ /proj/branch │ │ spawn claude │ │
183
+ │ │ /proj/branch │ │ PTY session │ │
184
184
  │ │ /commands │ │ timeouts │ │
185
- │ └────────────────┘ │ kill │ │
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
- | **TaskRunner** | `task-runner.js` | Runs `claude -p "task"` as a child process. Monitors timeouts. Can kill the process on cancellation. Emits events: complete, error, timeout |
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
- │ → TaskRunner.run(workDir, task)
259
- │ → claude -p "fix bug" --output-format text
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() → TaskRunner.run()
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 `claude -p` (e.g. `["--permission-mode", "auto"]`). Can also be set per-project to override |
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** → runs `claude -p "task"` immediately, replies with `⏳ Running...`
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
- claude -p "task" --output-format text
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
- The claude process is working...
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
- claude finished:
762
- exit 0 → "✅ Done" + stdout
763
- exit N → "❌ Error" + stderr
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
- ### What Claude receives
773
+ ### How Claude runs
773
774
 
774
- The command that gets executed:
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
- ```bash
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, matching the quality of a full interactive session.
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 adds `--continue` to subsequent tasks in the same workDir. This means Claude remembers previous tasks and their context — just like working in an interactive terminal session.
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 `result` field from Claude's JSON output — the text response to your task.
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 passed to claude as an array argument, not through the shell:
882
+ Task text is written to the PTY session's stdin, not passed through a shell command:
884
883
 
885
884
  ```js
886
- // Like this (safe):
887
- spawn('claude', ['-p', userText], { ... })
885
+ // PTY session — text goes to Claude's interactive prompt:
886
+ ptyProcess.write(taskText + '\r')
888
887
 
889
- // NOT like this (dangerous):
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
- By default `claude -p` runs without tool permissions Claude can't use Edit, Bash, Read, etc.
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: process started
1024
- claude -p "add endpoint GET /users with pagination" --output-format text
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 claude processes are running in parallel:
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 claude processes running in parallel:
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
@@ -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 { TaskRunner } from './task-runner.js';
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
- const runner = new TaskRunner(logger, taskTimeout, taskLogger);
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
- const started = runner.run(workDir, task, claudeArgs, continueSession);
312
- queue.markStarted(workDir, started.pid);
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
+ }
@@ -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
@@ -158,9 +158,31 @@ function isNotifierDisabled () {
158
158
  return true;
159
159
  }
160
160
  // Skip notifications for listener-spawned tasks unless explicitly enabled
161
- return process.env.CLAUDE_NOTIFY_FROM_LISTENER === '1'
162
- && process.env.CLAUDE_NOTIFY_AFTER_LISTENER !== '1';
161
+ if (process.env.CLAUDE_NOTIFY_FROM_LISTENER === '1'
162
+ && process.env.CLAUDE_NOTIFY_AFTER_LISTENER !== '1') {
163
+ return 'listener-only';
164
+ }
165
+ return false;
166
+ }
163
167
 
168
+ function writePtySignalFile (event) {
169
+ const sessionId = event.session_id || 'unknown';
170
+ const cwd = event.cwd || process.cwd();
171
+ try {
172
+ fs.mkdirSync(PTY_SIGNAL_DIR, { recursive: true });
173
+ const signalFile = path.join(PTY_SIGNAL_DIR, `${sessionId}.json`);
174
+ fs.writeFileSync(signalFile, JSON.stringify({
175
+ sessionId,
176
+ cwd,
177
+ lastAssistantMessage: event.last_assistant_message || '',
178
+ cost: event.total_cost_usd || 0,
179
+ numTurns: event.num_turns || 0,
180
+ durationMs: event.duration_ms || 0,
181
+ timestamp: Date.now(),
182
+ }));
183
+ } catch {
184
+ // silent fail
185
+ }
164
186
  }
165
187
 
166
188
  // ----------------------
@@ -280,7 +302,9 @@ async function sendTelegram (config, state) {
280
302
  body: JSON.stringify(body),
281
303
  });
282
304
  const data = await res.json();
283
- if (!data.ok) console.error('[telegram] HTML send failed:', JSON.stringify(data));
305
+ if (!data.ok) {
306
+ console.error('[telegram] HTML send failed:', JSON.stringify(data));
307
+ }
284
308
  if (data.ok && data.result?.message_id) {
285
309
  if (!state.sentMessages) {
286
310
  state.sentMessages = [];
@@ -637,7 +661,16 @@ process.stdin.on('end', async () => {
637
661
  const project = path.basename(cwd);
638
662
  const sessionId = event.session_id || 'default';
639
663
 
640
- if (isNotifierDisabled()) {
664
+ const disabled = isNotifierDisabled();
665
+ if (disabled === true) {
666
+ process.exit(0);
667
+ }
668
+
669
+ // For listener-only mode: write PTY signal file on Stop, then exit
670
+ if (disabled === 'listener-only') {
671
+ if (eventType === 'Stop') {
672
+ writePtySignalFile(event);
673
+ }
641
674
  process.exit(0);
642
675
  }
643
676
 
@@ -695,7 +728,7 @@ process.stdin.on('end', async () => {
695
728
  label += `/${branch}`;
696
729
  labelHtml += `/${escapeHtml(branch)}`;
697
730
  }
698
- labelHtml = `<code>${labelHtml}</code>`
731
+ labelHtml = `<code>${labelHtml}</code>`;
699
732
  const triggerLine = config.debug ? `\nTrigger: ${eventType}` : '';
700
733
 
701
734
  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.29",
4
+ "version": "1.1.32",
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",
@@ -1,195 +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
- this.logger.info(`Running task "${task.text}" in ${workDir}${continueSession ? ' (continue session)' : ' (new session)'}`);
63
- if (this.taskLogger) {
64
- this.taskLogger.logQuestion(task.project || 'unknown', task.branch || 'main', workDir, task.text);
65
- }
66
-
67
- const args = ['-p', task.text, '--output-format', 'json', ...claudeArgs];
68
- if (continueSession) {
69
- args.push('--continue');
70
- }
71
- const child = spawn('claude', args, {
72
- cwd: workDir,
73
- stdio: ['ignore', 'pipe', 'pipe'],
74
- windowsHide: true,
75
- shell: process.platform === 'win32',
76
- env: { ...process.env, CLAUDE_NOTIFY_FROM_LISTENER: '1' },
77
- });
78
-
79
- let stdout = '';
80
- let stderr = '';
81
-
82
- child.stdout.on('data', (chunk) => {
83
- stdout += chunk.toString();
84
- });
85
-
86
- child.stderr.on('data', (chunk) => {
87
- stderr += chunk.toString();
88
- });
89
-
90
- const timer = setTimeout(() => {
91
- this.logger.warn(`Task "${task.id}" timed out in ${workDir}`);
92
- this._killProcess(workDir);
93
- this.emit('timeout', workDir, task);
94
- }, this.timeout);
95
-
96
- child.on('close', (code) => {
97
- clearTimeout(timer);
98
- this.activeProcesses.delete(workDir);
99
-
100
- if (code === null) {
101
- // Process was killed (timeout or cancel)
102
- return;
103
- }
104
-
105
- if (code === 0) {
106
- const result = parseClaudeOutput(stdout);
107
- this.logger.info(`Task "${task.id}" completed in ${workDir} (session: ${result.sessionId || 'unknown'})`);
108
- if (this.taskLogger) {
109
- this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', result.text, 0);
110
- }
111
- this.emit('complete', workDir, task, result);
112
- } else {
113
- const errorMsg = stderr.trim() || `Process exited with code ${code}`;
114
- this.logger.error(`Task "${task.id}" failed in ${workDir}: ${errorMsg}`);
115
- if (this.taskLogger) {
116
- this.taskLogger.logAnswer(task.project || 'unknown', task.branch || 'main', errorMsg, code);
117
- }
118
- this.emit('error', workDir, task, errorMsg);
119
- }
120
- });
121
-
122
- child.on('error', (err) => {
123
- clearTimeout(timer);
124
- this.activeProcesses.delete(workDir);
125
- this.logger.error(`Task "${task.id}" spawn error: ${err.message}`);
126
- this.emit('error', workDir, task, err.message);
127
- });
128
-
129
- task.pid = child.pid;
130
- task.startedAt = new Date().toISOString();
131
- task.continueSession = continueSession;
132
- this.activeProcesses.set(workDir, { child, timer, task });
133
-
134
- return task;
135
- }
136
-
137
- /**
138
- * Cancel the active task in a workDir.
139
- */
140
- cancel (workDir) {
141
- this._killProcess(workDir);
142
- }
143
-
144
- /**
145
- * Check if a task is running in a workDir.
146
- */
147
- isRunning (workDir) {
148
- return this.activeProcesses.has(workDir);
149
- }
150
-
151
- /**
152
- * Get active task info for a workDir.
153
- */
154
- getActive (workDir) {
155
- const entry = this.activeProcesses.get(workDir);
156
- return entry?.task || null;
157
- }
158
-
159
- /**
160
- * Cancel all active tasks (for graceful shutdown).
161
- */
162
- cancelAll () {
163
- for (const workDir of this.activeProcesses.keys()) {
164
- this._killProcess(workDir);
165
- }
166
- }
167
-
168
- _killProcess (workDir) {
169
- const entry = this.activeProcesses.get(workDir);
170
- if (!entry) {
171
- return;
172
- }
173
- clearTimeout(entry.timer);
174
- try {
175
- if (process.platform === 'win32') {
176
- spawn('taskkill', ['/PID', String(entry.child.pid), '/T', '/F'], {
177
- stdio: 'ignore',
178
- windowsHide: true,
179
- });
180
- } else {
181
- entry.child.kill('SIGTERM');
182
- setTimeout(() => {
183
- try {
184
- entry.child.kill('SIGKILL');
185
- } catch {
186
- // already dead
187
- }
188
- }, 3000);
189
- }
190
- } catch {
191
- // process already dead
192
- }
193
- this.activeProcesses.delete(workDir);
194
- }
195
- }