claude-notification-plugin 1.1.65 → 1.1.75
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 +3 -0
- package/bin/constants.js +26 -0
- package/bin/listener-cli.js +26 -16
- package/commit-sha +1 -1
- package/listener/LISTENER-DETAILED.md +86 -7
- package/listener/jsonl-reader.js +388 -0
- package/listener/listener.js +142 -26
- package/listener/message-parser.js +6 -3
- package/listener/pty-runner.js +13 -1
- package/notifier/notifier.js +72 -12
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-notification-plugin",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.75",
|
|
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
|
@@ -134,6 +134,9 @@ ENV: `CLAUDE_NOTIFY_VOICE`
|
|
|
134
134
|
**notifyOnWaiting** — Notify when Claude is waiting for input. Default: **false**
|
|
135
135
|
ENV: `CLAUDE_NOTIFY_WAITING`
|
|
136
136
|
|
|
137
|
+
**notifyOnPermission** — Notify when Claude asks for tool permission (e.g. file edit confirmation). Default: **true**
|
|
138
|
+
ENV: `CLAUDE_NOTIFY_ON_PERMISSION`
|
|
139
|
+
|
|
137
140
|
**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.
|
|
138
141
|
ENV: `CLAUDE_NOTIFY_WEBHOOK_URL`
|
|
139
142
|
|
package/bin/constants.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
1
2
|
import os from 'os';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
|
|
@@ -39,6 +40,31 @@ export const SHORTCUT_DIR = path.join(
|
|
|
39
40
|
export const SHORTCUT_PATH = path.join(SHORTCUT_DIR, SHORTCUT_NAME);
|
|
40
41
|
export const APP_ID = 'Claude Notify';
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Find the alias of the project marked as default (isDefault: true).
|
|
45
|
+
* Falls back to the first project key if none is marked.
|
|
46
|
+
*/
|
|
47
|
+
export function getDefaultProject (projects) {
|
|
48
|
+
if (!projects || typeof projects !== 'object') {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
for (const [alias, proj] of Object.entries(projects)) {
|
|
52
|
+
if (typeof proj === 'object' && proj.isDefault) {
|
|
53
|
+
return alias;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Fallback: first project
|
|
57
|
+
const keys = Object.keys(projects);
|
|
58
|
+
return keys.length > 0 ? keys[0] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Save config object back to CONFIG_PATH.
|
|
63
|
+
*/
|
|
64
|
+
export function saveConfig (config) {
|
|
65
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
// Plugin identity
|
|
43
69
|
export const HOOK_COMMAND = 'claude-notify';
|
|
44
70
|
export const MARKETPLACE_KEY = 'bazilio-plugins';
|
package/bin/listener-cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import readline from 'readline';
|
|
|
6
6
|
import { spawn, execSync } from 'child_process';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import {
|
|
9
|
-
HOME, CLAUDE_DIR, CONFIG_PATH, PID_PATH, LISTENER_LOG_FILENAME,
|
|
9
|
+
HOME, CLAUDE_DIR, CONFIG_PATH, PID_PATH, LISTENER_LOG_FILENAME, getDefaultProject,
|
|
10
10
|
} from './constants.js';
|
|
11
11
|
|
|
12
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -116,7 +116,7 @@ async function startDaemon () {
|
|
|
116
116
|
console.error(JSON.stringify({
|
|
117
117
|
listener: {
|
|
118
118
|
projects: {
|
|
119
|
-
|
|
119
|
+
myproject: { path: '/path/to/your/project', isDefault: true },
|
|
120
120
|
},
|
|
121
121
|
},
|
|
122
122
|
}, null, 2));
|
|
@@ -453,7 +453,10 @@ async function setupListener () {
|
|
|
453
453
|
maxTotalTasks: L.maxTotalTasks ?? 50,
|
|
454
454
|
logDir: L.logDir || path.join(HOME, '.claude'),
|
|
455
455
|
taskLogDir: L.taskLogDir || path.join(HOME, '.claude'),
|
|
456
|
-
projectPath:
|
|
456
|
+
projectPath: (() => {
|
|
457
|
+
const defAlias = getDefaultProject(L.projects);
|
|
458
|
+
return defAlias ? (L.projects[defAlias]?.path || '') : '';
|
|
459
|
+
})(),
|
|
457
460
|
};
|
|
458
461
|
|
|
459
462
|
const rl = readline.createInterface({
|
|
@@ -520,30 +523,41 @@ Permission mode for claude -p (tools access):
|
|
|
520
523
|
|
|
521
524
|
// --- Default project ---
|
|
522
525
|
console.log('');
|
|
523
|
-
const projectInput = await ask(rl, `Default project path [${defaults.projectPath || '(none)'}]: `);
|
|
524
|
-
const rawProjectPath = projectInput || defaults.projectPath;
|
|
525
|
-
|
|
526
526
|
L.projects = L.projects || {};
|
|
527
|
+
const currentDefaultAlias = getDefaultProject(L.projects);
|
|
528
|
+
const currentDefaultPath = currentDefaultAlias ? (L.projects[currentDefaultAlias]?.path || '') : '';
|
|
529
|
+
|
|
530
|
+
const projectInput = await ask(rl, `Default project path [${currentDefaultPath || '(none)'}]: `);
|
|
531
|
+
const rawProjectPath = projectInput || currentDefaultPath;
|
|
527
532
|
let hasValidProject = false;
|
|
528
533
|
|
|
529
534
|
if (rawProjectPath) {
|
|
530
535
|
const validatedPath = await validateProjectPath(rl, rawProjectPath);
|
|
531
536
|
if (validatedPath) {
|
|
532
|
-
|
|
533
|
-
|
|
537
|
+
let aliasForDefault = currentDefaultAlias;
|
|
538
|
+
if (!aliasForDefault) {
|
|
539
|
+
const aliasInput = await ask(rl, 'Alias for default project: ');
|
|
540
|
+
aliasForDefault = aliasInput && isValidAlias(aliasInput) ? aliasInput : 'main';
|
|
541
|
+
}
|
|
542
|
+
L.projects[aliasForDefault] = L.projects[aliasForDefault] || {};
|
|
543
|
+
L.projects[aliasForDefault].path = validatedPath;
|
|
544
|
+
// Set isDefault on this project, clear from others
|
|
545
|
+
for (const proj of Object.values(L.projects)) {
|
|
546
|
+
if (typeof proj === 'object') {
|
|
547
|
+
delete proj.isDefault;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
L.projects[aliasForDefault].isDefault = true;
|
|
534
551
|
hasValidProject = true;
|
|
535
552
|
} else {
|
|
536
|
-
delete L.projects.default;
|
|
537
553
|
console.log(' \u26a0 Default project will not be set. Listener will not start without at least one project.');
|
|
538
554
|
}
|
|
539
555
|
} else {
|
|
540
|
-
delete L.projects.default;
|
|
541
556
|
console.log(' \u26a0 No default project configured. Listener will not start without at least one project.');
|
|
542
557
|
}
|
|
543
558
|
|
|
544
559
|
// --- Additional projects loop ---
|
|
545
|
-
|
|
546
|
-
const existingAliases = Object.keys(L.projects).filter(a => a !== 'default');
|
|
560
|
+
const existingAliases = Object.keys(L.projects);
|
|
547
561
|
if (existingAliases.length > 0) {
|
|
548
562
|
console.log(`\nExisting projects: ${existingAliases.join(', ')}`);
|
|
549
563
|
}
|
|
@@ -563,10 +577,6 @@ Permission mode for claude -p (tools access):
|
|
|
563
577
|
console.log(' \u26a0 Alias cannot be empty.');
|
|
564
578
|
continue;
|
|
565
579
|
}
|
|
566
|
-
if (alias === 'default') {
|
|
567
|
-
console.log(' \u26a0 "default" is reserved. Choose a different name.');
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
580
|
if (!isValidAlias(alias)) {
|
|
571
581
|
console.log(' \u26a0 Invalid alias. Allowed characters: a-z, A-Z, 0-9, -, _');
|
|
572
582
|
continue;
|
package/commit-sha
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
09e7c656d459f08696ec5408b01cb3ca67045d52
|
|
@@ -204,6 +204,7 @@ Running two listeners is impossible — the PID file prevents it. And this is im
|
|
|
204
204
|
| **WorktreeManager** | `worktree-manager.js` | Creates and removes git worktrees. Auto-discovery via `git worktree list`. Maps `&project/branch` to a path on disk |
|
|
205
205
|
| **Logger** | `logger.js` | Writes operational log to `~/.claude/.cc-n-listener.log`. Rotation when exceeding 5 MB (old file → `.log.old`) |
|
|
206
206
|
| **TaskLogger** | `task-logger.js` | Writes task Q&A logs (questions to Claude and answers). Separate file per project/branch. Rotation at 5 MB |
|
|
207
|
+
| **JsonlReader** | `jsonl-reader.js` | Reads Claude Code's structured JSONL session files incrementally. Provides clean, semantic live console content (text responses, tool calls) instead of raw PTY output |
|
|
207
208
|
|
|
208
209
|
---
|
|
209
210
|
|
|
@@ -762,14 +763,91 @@ Shows a brief reference for all commands.
|
|
|
762
763
|
|
|
763
764
|
### Live console
|
|
764
765
|
|
|
765
|
-
When **`liveConsole`** is enabled (default: `true`), the "⏳ Running..." message in Telegram is periodically updated with
|
|
766
|
+
When **`liveConsole`** is enabled (default: `true`), the "⏳ Running..." message in Telegram is periodically updated with what Claude is doing in real-time.
|
|
766
767
|
|
|
767
|
-
|
|
768
|
+
#### Data source: JSONL vs PTY
|
|
768
769
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
770
|
+
The live console can read data from two sources:
|
|
771
|
+
|
|
772
|
+
1. **JSONL session files** (default, preferred) — Claude Code writes structured JSONL files to `~/.claude/projects/{encodedCwd}/{sessionId}.jsonl`. Each line is a full JSON message with role, content (text, tool_use, thinking), model, timestamps, and cost. This gives clean, semantic output: the actual text Claude wrote and which tools it called.
|
|
773
|
+
|
|
774
|
+
2. **PTY buffer** (fallback) — raw terminal output from the pseudo-terminal, cleaned from ANSI escape codes and Claude Code UI chrome. This is a legacy approach that produces noisier output.
|
|
775
|
+
|
|
776
|
+
Controlled by the `liveConsoleSource` config parameter:
|
|
777
|
+
- `"auto"` (default) — try JSONL first, fall back to PTY buffer
|
|
778
|
+
- `"jsonl"` — use only JSONL (no output if file not found)
|
|
779
|
+
- `"pty"` — use only PTY buffer (legacy behavior)
|
|
780
|
+
|
|
781
|
+
#### How the JSONL path is resolved
|
|
782
|
+
|
|
783
|
+
The JSONL file path follows the convention:
|
|
784
|
+
```
|
|
785
|
+
~/.claude/projects/{encodedCwd}/{sessionId}.jsonl
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
**Step 1: Determine `sessionId`**
|
|
789
|
+
|
|
790
|
+
When Claude Code starts a session, its `SessionStart` hook writes a signal file `rdy_{sessionId}.json` to `~/.claude/pty-signals/`. The listener's PTY runner already polls this directory. On receiving a `ready` signal, the runner extracts `sessionId` from the filename and stores it in the session state.
|
|
791
|
+
|
|
792
|
+
**Step 2: Encode `cwd` into a project directory name**
|
|
793
|
+
|
|
794
|
+
Claude Code encodes the working directory path into a folder name by replacing special characters with dashes:
|
|
795
|
+
|
|
796
|
+
| Character | Replacement |
|
|
797
|
+
|---|---|
|
|
798
|
+
| `:` | `-` |
|
|
799
|
+
| `\` | `-` |
|
|
800
|
+
| `/` | `-` |
|
|
801
|
+
| `.` | `-` |
|
|
802
|
+
| `_` | `-` |
|
|
803
|
+
|
|
804
|
+
All other characters (letters, digits, existing `-`) are preserved as-is.
|
|
805
|
+
|
|
806
|
+
Examples:
|
|
807
|
+
|
|
808
|
+
| Working directory | Encoded project dir |
|
|
809
|
+
|---|---|
|
|
810
|
+
| `D:\DEV\FA\_pub\claude-notification-plugin` | `D--DEV-FA--pub-claude-notification-plugin` |
|
|
811
|
+
| `D:\DEV\FA\_cur\work-fast--testing` | `D--DEV-FA--cur-work-fast--testing` |
|
|
812
|
+
| `/home/user/projects/api-server` | `-home-user-projects-api-server` |
|
|
813
|
+
|
|
814
|
+
The double dashes (`--`) arise naturally from consecutive special characters, e.g. `\_` → `-` + `-` = `--`.
|
|
815
|
+
|
|
816
|
+
**Step 3: Construct the full path and open a reader**
|
|
817
|
+
|
|
818
|
+
```
|
|
819
|
+
sessionId = "08b1fa75-91fe-4c1e-a47e-db9b99af7fb5"
|
|
820
|
+
cwd = "D:\DEV\FA\_pub\claude-notification-plugin"
|
|
821
|
+
↓ encode
|
|
822
|
+
encodedCwd = "D--DEV-FA--pub-claude-notification-plugin"
|
|
823
|
+
↓ join
|
|
824
|
+
path = ~/.claude/projects/D--DEV-FA--pub-claude-notification-plugin/08b1fa75-91fe-4c1e-a47e-db9b99af7fb5.jsonl
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
If `sessionId` is not yet known (session hasn't started), the fallback picks the most recently modified `.jsonl` file in the project directory (within the last 2 minutes).
|
|
828
|
+
|
|
829
|
+
#### What is displayed
|
|
830
|
+
|
|
831
|
+
From the JSONL data, the live console extracts:
|
|
832
|
+
- **Text responses** — the actual text Claude wrote (from `content[].type === "text"`)
|
|
833
|
+
- **Tool calls** — formatted as `🔧 Read: config.json`, `🔧 $ npm test`, `🔧 Grep: pattern`, etc. (from `content[].type === "tool_use"`)
|
|
834
|
+
- **Thinking blocks** are skipped (not shown)
|
|
835
|
+
|
|
836
|
+
This is far more informative than raw PTY output: you see the real tool calls and actual responses, not garbled terminal fragments.
|
|
837
|
+
|
|
838
|
+
#### Incremental reading
|
|
839
|
+
|
|
840
|
+
The JSONL reader tracks its byte offset in the file and only reads new data on each poll. Since JSONL files are append-only, this is safe and efficient — no re-reading of the entire file.
|
|
841
|
+
|
|
842
|
+
#### Configuration
|
|
843
|
+
|
|
844
|
+
| Parameter | Default | Description |
|
|
845
|
+
|---|---|---|
|
|
846
|
+
| `liveConsole` | `true` | Enable/disable live console |
|
|
847
|
+
| `liveConsoleIntervalMillis` | `1000` | Update interval in milliseconds |
|
|
848
|
+
| `liveConsoleMaxOutputChars` | `300` | Max chars of PTY output per update |
|
|
849
|
+
| `liveConsoleSource` | `"auto"` | Data source: `"auto"`, `"jsonl"`, or `"pty"` |
|
|
850
|
+
| `jsonlMaxContentChars` | `500` | Max chars of JSONL content per update |
|
|
773
851
|
|
|
774
852
|
### PTY logs
|
|
775
853
|
|
|
@@ -792,8 +870,9 @@ Send `/pty` or `/pty &project` in Telegram to get instant diagnostics:
|
|
|
792
870
|
- Buffer size in bytes
|
|
793
871
|
- Elapsed time since task start
|
|
794
872
|
- Whether live console interval is active
|
|
873
|
+
- Whether JSONL source is active
|
|
795
874
|
- Whether PTY log stream is writing
|
|
796
|
-
- Last 15 lines of
|
|
875
|
+
- Last 15 lines of output (from JSONL if available, otherwise from PTY buffer)
|
|
797
876
|
|
|
798
877
|
---
|
|
799
878
|
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { CLAUDE_DIR } from '../bin/constants.js';
|
|
4
|
+
|
|
5
|
+
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Encode a cwd path into the Claude Code project directory name.
|
|
9
|
+
* Replaces : \ / . _ with dashes.
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* D:\DEV\FA\_pub\my-project → D--DEV-FA--pub-my-project
|
|
13
|
+
* /home/user/projects/api → -home-user-projects-api
|
|
14
|
+
*/
|
|
15
|
+
export function cwdToProjectDir (cwd) {
|
|
16
|
+
return cwd.replace(/[:\\/._]/g, '-');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Find the JSONL file path for a given cwd and sessionId.
|
|
21
|
+
* Returns null if the file does not exist.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveJsonlPath (cwd, sessionId) {
|
|
24
|
+
if (!sessionId) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const dirName = cwdToProjectDir(cwd);
|
|
28
|
+
const filePath = path.join(PROJECTS_DIR, dirName, `${sessionId}.jsonl`);
|
|
29
|
+
try {
|
|
30
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
31
|
+
return filePath;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find the most recently modified JSONL file for a given cwd.
|
|
39
|
+
* Used as a fallback when sessionId is not yet known.
|
|
40
|
+
* Returns null if no fresh file is found within maxAgeMs.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveJsonlByMtime (cwd, maxAgeMs = 120_000) {
|
|
43
|
+
const dirName = cwdToProjectDir(cwd);
|
|
44
|
+
const dirPath = path.join(PROJECTS_DIR, dirName);
|
|
45
|
+
let files;
|
|
46
|
+
try {
|
|
47
|
+
files = fs.readdirSync(dirPath);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let best = null;
|
|
53
|
+
let bestMtime = 0;
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
if (!f.endsWith('.jsonl')) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const stat = fs.statSync(path.join(dirPath, f));
|
|
62
|
+
if (stat.mtimeMs > bestMtime && now - stat.mtimeMs < maxAgeMs) {
|
|
63
|
+
bestMtime = stat.mtimeMs;
|
|
64
|
+
best = path.join(dirPath, f);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return best;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Incremental JSONL file reader.
|
|
76
|
+
* Reads new lines from a JSONL file since the last read.
|
|
77
|
+
*/
|
|
78
|
+
export class JsonlReader {
|
|
79
|
+
constructor (filePath, logger) {
|
|
80
|
+
this.filePath = filePath;
|
|
81
|
+
this.logger = logger || null;
|
|
82
|
+
this._offset = 0;
|
|
83
|
+
this._remainder = '';
|
|
84
|
+
this._lastAssistantText = '';
|
|
85
|
+
this._lastToolUse = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read new lines since last call, parse JSON, update internal state.
|
|
90
|
+
* Returns array of parsed JSONL objects (only new ones).
|
|
91
|
+
*/
|
|
92
|
+
readNew () {
|
|
93
|
+
let fd;
|
|
94
|
+
try {
|
|
95
|
+
fd = fs.openSync(this.filePath, 'r');
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const stat = fs.fstatSync(fd);
|
|
102
|
+
if (stat.size <= this._offset) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const buf = Buffer.alloc(stat.size - this._offset);
|
|
107
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, this._offset);
|
|
108
|
+
if (bytesRead === 0) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
this._offset += bytesRead;
|
|
112
|
+
|
|
113
|
+
const chunk = this._remainder + buf.toString('utf-8', 0, bytesRead);
|
|
114
|
+
const lines = chunk.split('\n');
|
|
115
|
+
// Last element may be incomplete — save as remainder
|
|
116
|
+
this._remainder = lines.pop() || '';
|
|
117
|
+
|
|
118
|
+
const entries = [];
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
const trimmed = line.trim();
|
|
121
|
+
if (!trimmed) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const obj = JSON.parse(trimmed);
|
|
126
|
+
entries.push(obj);
|
|
127
|
+
this._processEntry(obj);
|
|
128
|
+
} catch {
|
|
129
|
+
// skip malformed lines
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return entries;
|
|
133
|
+
} finally {
|
|
134
|
+
fs.closeSync(fd);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Process a parsed JSONL entry to update last assistant content.
|
|
140
|
+
*/
|
|
141
|
+
_processEntry (entry) {
|
|
142
|
+
if (entry.message?.role !== 'assistant') {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const content = entry.message?.content;
|
|
147
|
+
if (!Array.isArray(content)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const block of content) {
|
|
152
|
+
if (block.type === 'text' && block.text) {
|
|
153
|
+
this._lastAssistantText = block.text;
|
|
154
|
+
this._lastToolUse = null; // text supersedes tool_use display
|
|
155
|
+
} else if (block.type === 'tool_use') {
|
|
156
|
+
this._lastToolUse = {
|
|
157
|
+
name: block.name || 'unknown',
|
|
158
|
+
input: block.input || {},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// thinking blocks are ignored for display
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get formatted display text from the last assistant message.
|
|
167
|
+
* Returns a short summary suitable for Telegram live console.
|
|
168
|
+
*/
|
|
169
|
+
getDisplayContent (maxChars = 300) {
|
|
170
|
+
const parts = [];
|
|
171
|
+
|
|
172
|
+
if (this._lastToolUse) {
|
|
173
|
+
parts.push(formatToolUse(this._lastToolUse));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this._lastAssistantText) {
|
|
177
|
+
parts.push(this._lastAssistantText);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (parts.length === 0) {
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let result = parts.join('\n\n');
|
|
185
|
+
if (result.length > maxChars) {
|
|
186
|
+
result = result.slice(-maxChars);
|
|
187
|
+
// Trim to last complete line
|
|
188
|
+
const nlIdx = result.indexOf('\n');
|
|
189
|
+
if (nlIdx > 0) {
|
|
190
|
+
result = result.slice(nlIdx + 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reset reader to re-read from the beginning.
|
|
198
|
+
*/
|
|
199
|
+
reset () {
|
|
200
|
+
this._offset = 0;
|
|
201
|
+
this._remainder = '';
|
|
202
|
+
this._lastAssistantText = '';
|
|
203
|
+
this._lastToolUse = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Format a tool_use block into a short display string.
|
|
209
|
+
*/
|
|
210
|
+
function formatToolUse (tool) {
|
|
211
|
+
const name = tool.name || '';
|
|
212
|
+
const input = tool.input || {};
|
|
213
|
+
const trunc = (s, n) => (typeof s === 'string' && s.length > n ? s.slice(0, n - 1) + '…' : s);
|
|
214
|
+
switch (name) {
|
|
215
|
+
case 'Read':
|
|
216
|
+
case 'Write':
|
|
217
|
+
case 'Edit':
|
|
218
|
+
return input.file_path
|
|
219
|
+
? `🔧 ${name}: ${path.basename(input.file_path)}`
|
|
220
|
+
: `🔧 ${name}`;
|
|
221
|
+
case 'Bash':
|
|
222
|
+
return input.command
|
|
223
|
+
? `🔧 $ ${trunc(input.command, 80)}${input.run_in_background ? ' (bg)' : ''}${input.timeout ? ` (timeout ${input.timeout})` : ''}`
|
|
224
|
+
: '🔧 Bash';
|
|
225
|
+
case 'Grep':
|
|
226
|
+
if (input.pattern) {
|
|
227
|
+
const where = typeof input.path === 'string'
|
|
228
|
+
? path.basename(input.path)
|
|
229
|
+
: (typeof input.glob === 'string' ? input.glob : '');
|
|
230
|
+
|
|
231
|
+
const flags = [];
|
|
232
|
+
if (input['-n']) {
|
|
233
|
+
flags.push('-n');
|
|
234
|
+
}
|
|
235
|
+
if (input['-C']) {
|
|
236
|
+
flags.push(`-C ${input['-C']}`);
|
|
237
|
+
}
|
|
238
|
+
if (!input['-C'] && (typeof input.context === 'number' || typeof input.context === 'string')) {
|
|
239
|
+
flags.push(`-C ${input.context}`);
|
|
240
|
+
}
|
|
241
|
+
if (input['-i']) {
|
|
242
|
+
flags.push('-i');
|
|
243
|
+
}
|
|
244
|
+
if (input['-A']) {
|
|
245
|
+
flags.push(`-A ${input['-A']}`);
|
|
246
|
+
}
|
|
247
|
+
if (input['-B']) {
|
|
248
|
+
flags.push(`-B ${input['-B']}`);
|
|
249
|
+
}
|
|
250
|
+
if (input.head_limit) {
|
|
251
|
+
flags.push(`head ${input.head_limit}`);
|
|
252
|
+
}
|
|
253
|
+
const flagStr = flags.length ? ` ${flags.join(' ')}` : '';
|
|
254
|
+
|
|
255
|
+
return where
|
|
256
|
+
? `🔧 Grep${flagStr}: ${trunc(input.pattern, 60)} in ${trunc(where, 30)}`
|
|
257
|
+
: `🔧 Grep${flagStr}: ${trunc(input.pattern, 80)}`;
|
|
258
|
+
}
|
|
259
|
+
return '🔧 Grep';
|
|
260
|
+
case 'Glob':
|
|
261
|
+
if (input.pattern) {
|
|
262
|
+
const p = typeof input.path === 'string' ? path.basename(input.path) : '';
|
|
263
|
+
return p ? `🔧 Glob: ${trunc(input.pattern, 60)} in ${trunc(p, 30)}` : `🔧 Glob: ${trunc(input.pattern, 80)}`;
|
|
264
|
+
}
|
|
265
|
+
return '🔧 Glob';
|
|
266
|
+
case 'Agent':
|
|
267
|
+
if (input.description) {
|
|
268
|
+
const bg = input.run_in_background ? ' (bg)' : '';
|
|
269
|
+
const st = typeof input.subagent_type === 'string' && input.subagent_type.trim()
|
|
270
|
+
? ` [${input.subagent_type.trim()}]`
|
|
271
|
+
: '';
|
|
272
|
+
return `🔧 Agent${bg}${st}: ${trunc(input.description, 80)}`;
|
|
273
|
+
}
|
|
274
|
+
return '🔧 Agent';
|
|
275
|
+
case 'Skill':
|
|
276
|
+
return input.skill
|
|
277
|
+
? `🔧 Skill: ${input.skill}${input.args ? ` ${trunc(String(input.args), 80)}` : ''}`
|
|
278
|
+
: '🔧 Skill';
|
|
279
|
+
case 'WebFetch':
|
|
280
|
+
if (input.url) {
|
|
281
|
+
const hasPrompt = typeof input.prompt === 'string' && input.prompt.trim();
|
|
282
|
+
return `🔧 Fetch${hasPrompt ? '*' : ''}: ${trunc(input.url, 80)}`;
|
|
283
|
+
}
|
|
284
|
+
return '🔧 WebFetch';
|
|
285
|
+
case 'WebSearch':
|
|
286
|
+
return input.query ? `🔧 Search: ${input.query}` : '🔧 WebSearch';
|
|
287
|
+
case 'ToolSearch':
|
|
288
|
+
return input.query ? `🔧 ToolSearch: ${trunc(input.query, 200)}` : '🔧 ToolSearch';
|
|
289
|
+
case 'TaskCreate':
|
|
290
|
+
return input.subject
|
|
291
|
+
? `🔧 Task+: ${trunc(input.subject, 100)}`
|
|
292
|
+
: '🔧 TaskCreate';
|
|
293
|
+
case 'TaskUpdate':
|
|
294
|
+
return input.taskId && input.status
|
|
295
|
+
? `🔧 Task#${input.taskId}: ${input.status}`
|
|
296
|
+
: (input.taskId ? `🔧 Task#${input.taskId}` : '🔧 TaskUpdate');
|
|
297
|
+
case 'ExitPlanMode':
|
|
298
|
+
return input.planFilePath
|
|
299
|
+
? `🔧 ExitPlanMode: ${path.basename(input.planFilePath)}`
|
|
300
|
+
: '🔧 ExitPlanMode';
|
|
301
|
+
case 'Task':
|
|
302
|
+
if (input.description) {
|
|
303
|
+
const st = typeof input.subagent_type === 'string' && input.subagent_type.trim()
|
|
304
|
+
? ` [${input.subagent_type.trim()}]`
|
|
305
|
+
: '';
|
|
306
|
+
return `🔧 Task${st}: ${trunc(input.description, 80)}`;
|
|
307
|
+
}
|
|
308
|
+
return '🔧 Task';
|
|
309
|
+
case 'TaskOutput':
|
|
310
|
+
if (input.task_id) {
|
|
311
|
+
return `🔧 Task#${input.task_id}: output${input.timeout ? ` (timeout ${input.timeout})` : ''}`;
|
|
312
|
+
}
|
|
313
|
+
return '🔧 TaskOutput';
|
|
314
|
+
case 'AskUserQuestion': {
|
|
315
|
+
const qs = Array.isArray(input.questions) ? input.questions : [];
|
|
316
|
+
if (qs.length > 0) {
|
|
317
|
+
const first = qs[0] || {};
|
|
318
|
+
const head = (typeof first.header === 'string' && first.header.trim())
|
|
319
|
+
? first.header.trim()
|
|
320
|
+
: (typeof first.question === 'string' ? first.question.trim() : '');
|
|
321
|
+
const suffix = qs.length > 1 ? ` (+${qs.length - 1})` : '';
|
|
322
|
+
if (head) {
|
|
323
|
+
return `🔧 Ask: ${trunc(head, 120)}${suffix}`;
|
|
324
|
+
}
|
|
325
|
+
return `🔧 AskUserQuestion${suffix}`;
|
|
326
|
+
}
|
|
327
|
+
return '🔧 AskUserQuestion';
|
|
328
|
+
}
|
|
329
|
+
default:
|
|
330
|
+
if (name.startsWith('mcp__playwright__browser_')) {
|
|
331
|
+
const action = name.slice('mcp__playwright__browser_'.length);
|
|
332
|
+
switch (action) {
|
|
333
|
+
case 'navigate':
|
|
334
|
+
return input.url ? `🔧 PW nav: ${trunc(input.url, 80)}` : '🔧 PW nav';
|
|
335
|
+
case 'click': {
|
|
336
|
+
const ref = typeof input.ref === 'string' ? input.ref : '';
|
|
337
|
+
const el = typeof input.element === 'string' ? input.element : '';
|
|
338
|
+
return `🔧 PW click: ${trunc(ref || el || '', 80)}`.trim();
|
|
339
|
+
}
|
|
340
|
+
case 'type': {
|
|
341
|
+
const ref = typeof input.ref === 'string' ? input.ref : '';
|
|
342
|
+
const text = typeof input.text === 'string' ? input.text : '';
|
|
343
|
+
return `🔧 PW type: ${trunc(ref || '', 40)}${text ? ` (${text.length} chars)` : ''}`.trim();
|
|
344
|
+
}
|
|
345
|
+
case 'take_screenshot':
|
|
346
|
+
return input.filename ? `🔧 PW shot: ${trunc(input.filename, 80)}` : '🔧 PW shot';
|
|
347
|
+
case 'wait_for':
|
|
348
|
+
return input.time ? `🔧 PW wait: ${input.time}` : '🔧 PW wait';
|
|
349
|
+
case 'console_messages':
|
|
350
|
+
return input.level ? `🔧 PW console: ${input.level}` : '🔧 PW console';
|
|
351
|
+
case 'snapshot':
|
|
352
|
+
return '🔧 PW snapshot';
|
|
353
|
+
default:
|
|
354
|
+
return `🔧 PW: ${action}`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (name === 'mcp__sequential-thinking__sequentialthinking') {
|
|
358
|
+
const n = input.thoughtNumber;
|
|
359
|
+
const total = input.totalThoughts;
|
|
360
|
+
const head = (typeof n === 'number' || typeof n === 'string') && (typeof total === 'number' || typeof total === 'string')
|
|
361
|
+
? `${n}/${total} `
|
|
362
|
+
: '';
|
|
363
|
+
const thought = typeof input.thought === 'string' ? trunc(input.thought.trim(), 80) : '';
|
|
364
|
+
return `🔧 Think: ${head}${thought}`.trim();
|
|
365
|
+
}
|
|
366
|
+
if (name === 'mcp__serena__list_dir') {
|
|
367
|
+
return input.relative_path ? `🔧 Serena ls: ${trunc(input.relative_path, 80)}` : '🔧 Serena ls';
|
|
368
|
+
}
|
|
369
|
+
if (name === 'mcp__serena__get_symbols_overview') {
|
|
370
|
+
return input.relative_path ? `🔧 Serena symbols: ${trunc(input.relative_path, 80)}` : '🔧 Serena symbols';
|
|
371
|
+
}
|
|
372
|
+
if (name.startsWith('mcp__')) {
|
|
373
|
+
if (name === 'mcp__context7__resolve-library-id') {
|
|
374
|
+
return input.libraryName
|
|
375
|
+
? `🔧 C7 lib: ${trunc(input.libraryName, 60)}`
|
|
376
|
+
: '🔧 C7 resolve';
|
|
377
|
+
}
|
|
378
|
+
if (name === 'mcp__context7__query-docs') {
|
|
379
|
+
const lib = typeof input.libraryId === 'string' ? input.libraryId : '';
|
|
380
|
+
const q = typeof input.query === 'string' ? input.query : '';
|
|
381
|
+
return `🔧 C7 docs: ${trunc(q || lib || '', 80)}`.trim();
|
|
382
|
+
}
|
|
383
|
+
const parts = name.split('__');
|
|
384
|
+
return parts.length >= 3 ? `🔧 MCP ${parts[1]}: ${parts[2]}` : `🔧 ${name}`;
|
|
385
|
+
}
|
|
386
|
+
return `🔧 ${name}`;
|
|
387
|
+
}
|
|
388
|
+
}
|
package/listener/listener.js
CHANGED
|
@@ -11,7 +11,8 @@ import { WorkQueue } from './work-queue.js';
|
|
|
11
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
|
-
import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME } from '../bin/constants.js';
|
|
14
|
+
import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME, getDefaultProject, saveConfig } from '../bin/constants.js';
|
|
15
|
+
import { JsonlReader, resolveJsonlPath, resolveJsonlByMtime } from './jsonl-reader.js';
|
|
15
16
|
|
|
16
17
|
// ----------------------
|
|
17
18
|
// CRASH PROTECTION
|
|
@@ -138,6 +139,11 @@ const sessions = new Map();
|
|
|
138
139
|
const freshSessionDirs = new Set();
|
|
139
140
|
// Live console intervals per workDir
|
|
140
141
|
const liveConsoleTimers = new Map();
|
|
142
|
+
// JSONL readers per workDir (for live console from structured session data)
|
|
143
|
+
const jsonlReaders = new Map();
|
|
144
|
+
// Live console source: "jsonl" | "pty" | "auto" (default: "auto")
|
|
145
|
+
const liveConsoleSource = listenerConfig.liveConsoleSource || 'auto';
|
|
146
|
+
const jsonlMaxContentChars = listenerConfig.jsonlMaxContentChars || 500;
|
|
141
147
|
|
|
142
148
|
logger.info('Listener started');
|
|
143
149
|
logger.info(`Projects: ${JSON.stringify(Object.keys(listenerConfig.projects))}`);
|
|
@@ -322,6 +328,49 @@ function shouldContinueSession (workDir) {
|
|
|
322
328
|
return sessions.has(workDir);
|
|
323
329
|
}
|
|
324
330
|
|
|
331
|
+
function _initJsonlReader (workDir) {
|
|
332
|
+
const sessionId = runner.getSessionId(workDir);
|
|
333
|
+
const jsonlPath = sessionId
|
|
334
|
+
? resolveJsonlPath(workDir, sessionId)
|
|
335
|
+
: resolveJsonlByMtime(workDir);
|
|
336
|
+
if (jsonlPath) {
|
|
337
|
+
const reader = new JsonlReader(jsonlPath, logger);
|
|
338
|
+
jsonlReaders.set(workDir, reader);
|
|
339
|
+
logger.info(`JSONL reader initialized: ${jsonlPath}`);
|
|
340
|
+
return reader;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _getJsonlContent (workDir) {
|
|
346
|
+
let reader = jsonlReaders.get(workDir);
|
|
347
|
+
if (!reader) {
|
|
348
|
+
reader = _initJsonlReader(workDir);
|
|
349
|
+
}
|
|
350
|
+
if (!reader) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
reader.readNew();
|
|
354
|
+
return reader.getDisplayContent(jsonlMaxContentChars);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function _getPtyContent (workDir) {
|
|
358
|
+
const raw = runner.getBuffer(workDir);
|
|
359
|
+
if (!raw) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const cleaned = cleanPtyOutput(raw);
|
|
363
|
+
if (!cleaned) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const tail = cleaned.length > liveConsoleMaxOutputChars
|
|
367
|
+
? cleaned.slice(-liveConsoleMaxOutputChars)
|
|
368
|
+
: cleaned;
|
|
369
|
+
return cleaned.length > liveConsoleMaxOutputChars
|
|
370
|
+
? tail.slice(tail.indexOf('\n') + 1)
|
|
371
|
+
: tail;
|
|
372
|
+
}
|
|
373
|
+
|
|
325
374
|
function startLiveConsole (workDir, messageId, header) {
|
|
326
375
|
stopLiveConsole(workDir);
|
|
327
376
|
if (!liveConsoleEnabled || !messageId) {
|
|
@@ -330,22 +379,13 @@ function startLiveConsole (workDir, messageId, header) {
|
|
|
330
379
|
let lastSentText = '';
|
|
331
380
|
const timer = setInterval(async () => {
|
|
332
381
|
try {
|
|
333
|
-
|
|
334
|
-
if (
|
|
335
|
-
|
|
382
|
+
let output = null;
|
|
383
|
+
if (liveConsoleSource === 'jsonl' || liveConsoleSource === 'auto') {
|
|
384
|
+
output = _getJsonlContent(workDir);
|
|
336
385
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
return;
|
|
386
|
+
if (!output && (liveConsoleSource === 'pty' || liveConsoleSource === 'auto')) {
|
|
387
|
+
output = _getPtyContent(workDir);
|
|
340
388
|
}
|
|
341
|
-
// Take the tail that fits
|
|
342
|
-
const tail = cleaned.length > liveConsoleMaxOutputChars
|
|
343
|
-
? cleaned.slice(-liveConsoleMaxOutputChars)
|
|
344
|
-
: cleaned;
|
|
345
|
-
// Trim to last complete line if we sliced mid-line
|
|
346
|
-
const output = cleaned.length > liveConsoleMaxOutputChars
|
|
347
|
-
? tail.slice(tail.indexOf('\n') + 1)
|
|
348
|
-
: tail;
|
|
349
389
|
if (!output || output === lastSentText) {
|
|
350
390
|
return;
|
|
351
391
|
}
|
|
@@ -370,6 +410,7 @@ function stopLiveConsole (workDir) {
|
|
|
370
410
|
clearInterval(timer);
|
|
371
411
|
liveConsoleTimers.delete(workDir);
|
|
372
412
|
}
|
|
413
|
+
jsonlReaders.delete(workDir);
|
|
373
414
|
}
|
|
374
415
|
|
|
375
416
|
async function startTask (workDir, task) {
|
|
@@ -473,6 +514,8 @@ async function handleCommand (cmd, args) {
|
|
|
473
514
|
return handleNewSession(args);
|
|
474
515
|
case '/projects':
|
|
475
516
|
return handleProjects();
|
|
517
|
+
case '/setdefault':
|
|
518
|
+
return handleSetDefault(args);
|
|
476
519
|
case '/worktrees':
|
|
477
520
|
return handleWorktrees(args);
|
|
478
521
|
case '/worktree':
|
|
@@ -611,7 +654,7 @@ function handleQueue () {
|
|
|
611
654
|
|
|
612
655
|
async function handleCancel (args) {
|
|
613
656
|
const target = parseTarget(args);
|
|
614
|
-
const projectAlias = target?.project ||
|
|
657
|
+
const projectAlias = target?.project || getDefaultProject(listenerConfig.projects);
|
|
615
658
|
const branch = target?.branch || null;
|
|
616
659
|
|
|
617
660
|
let workDir;
|
|
@@ -662,7 +705,7 @@ function handleDrop (args) {
|
|
|
662
705
|
|
|
663
706
|
function handleClear (args) {
|
|
664
707
|
const target = parseTarget(args);
|
|
665
|
-
const projectAlias = target?.project ||
|
|
708
|
+
const projectAlias = target?.project || getDefaultProject(listenerConfig.projects);
|
|
666
709
|
const branch = target?.branch || null;
|
|
667
710
|
|
|
668
711
|
let workDir;
|
|
@@ -685,7 +728,7 @@ function handleClear (args) {
|
|
|
685
728
|
|
|
686
729
|
function handleNewSession (args) {
|
|
687
730
|
const target = parseTarget(args);
|
|
688
|
-
const projectAlias = target?.project ||
|
|
731
|
+
const projectAlias = target?.project || getDefaultProject(listenerConfig.projects);
|
|
689
732
|
const branch = target?.branch || null;
|
|
690
733
|
|
|
691
734
|
let workDir;
|
|
@@ -710,10 +753,12 @@ function handleNewSession (args) {
|
|
|
710
753
|
|
|
711
754
|
function handleProjects () {
|
|
712
755
|
const projects = listenerConfig.projects;
|
|
756
|
+
const defaultAlias = getDefaultProject(projects);
|
|
713
757
|
let text = '📂 <b>Projects:</b>\n';
|
|
714
758
|
for (const [alias, proj] of Object.entries(projects)) {
|
|
715
759
|
const projPath = typeof proj === 'string' ? proj : proj.path;
|
|
716
|
-
|
|
760
|
+
const icon = alias === defaultAlias ? '🏠 ' : '';
|
|
761
|
+
text += `\n${icon}<b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
|
|
717
762
|
const worktrees = typeof proj === 'object' ? proj.worktrees : null;
|
|
718
763
|
if (worktrees && Object.keys(worktrees).length > 0) {
|
|
719
764
|
for (const [branch, wtPath] of Object.entries(worktrees)) {
|
|
@@ -721,7 +766,63 @@ function handleProjects () {
|
|
|
721
766
|
}
|
|
722
767
|
}
|
|
723
768
|
}
|
|
724
|
-
|
|
769
|
+
|
|
770
|
+
const buttons = [];
|
|
771
|
+
// "Set Default" button
|
|
772
|
+
buttons.push([{ text: '🏠 Set Default', callback_data: '/setdefault' }]);
|
|
773
|
+
|
|
774
|
+
return { text, replyMarkup: { inline_keyboard: buttons } };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function handleSetDefault (args) {
|
|
778
|
+
const projects = listenerConfig.projects;
|
|
779
|
+
|
|
780
|
+
// No args — show inline keyboard with project list
|
|
781
|
+
if (!args || !args.trim()) {
|
|
782
|
+
const defaultAlias = getDefaultProject(projects);
|
|
783
|
+
const buttons = [];
|
|
784
|
+
for (const [alias, proj] of Object.entries(projects)) {
|
|
785
|
+
const projPath = typeof proj === 'string' ? proj : proj.path;
|
|
786
|
+
const icon = alias === defaultAlias ? '🏠 ' : '';
|
|
787
|
+
buttons.push([{
|
|
788
|
+
text: `${icon}${alias} — ${projPath}`,
|
|
789
|
+
callback_data: `/setdefault ${alias}`,
|
|
790
|
+
}]);
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
text: '🏠 <b>Select default project:</b>',
|
|
794
|
+
replyMarkup: { inline_keyboard: buttons },
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Args provided — set the default
|
|
799
|
+
const alias = args.trim();
|
|
800
|
+
if (!projects[alias]) {
|
|
801
|
+
return `❌ Project "<b>${escapeHtml(alias)}</b>" not found. Use /projects to list.`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Clear isDefault from all projects, set on chosen
|
|
805
|
+
for (const proj of Object.values(projects)) {
|
|
806
|
+
if (typeof proj === 'object') {
|
|
807
|
+
delete proj.isDefault;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const proj = projects[alias];
|
|
811
|
+
if (typeof proj === 'object') {
|
|
812
|
+
proj.isDefault = true;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Persist to config file
|
|
816
|
+
try {
|
|
817
|
+
saveConfig(config);
|
|
818
|
+
logger.info(`Default project changed to "${alias}"`);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
logger.error(`Failed to save config: ${err.message}`);
|
|
821
|
+
return `❌ Failed to save config: ${escapeHtml(err.message)}`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const projPath = typeof proj === 'string' ? proj : proj.path;
|
|
825
|
+
return `✅ Default project: <b>&${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
|
|
725
826
|
}
|
|
726
827
|
|
|
727
828
|
function handleWorktrees (args) {
|
|
@@ -830,17 +931,27 @@ function formatPtyInfo (project, branch, workDir, info) {
|
|
|
830
931
|
? formatDuration(Date.now() - new Date(info.startedAt).getTime())
|
|
831
932
|
: '-';
|
|
832
933
|
const liveTimer = liveConsoleTimers.has(workDir) ? '✅' : '❌';
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
934
|
+
const hasJsonl = jsonlReaders.has(workDir) ? '✅' : '❌';
|
|
935
|
+
|
|
936
|
+
// Prefer JSONL content if available, fall back to PTY buffer
|
|
937
|
+
let lastLines = '(empty)';
|
|
938
|
+
const jsonlContent = _getJsonlContent(workDir);
|
|
939
|
+
if (jsonlContent) {
|
|
940
|
+
lastLines = jsonlContent.split('\n').slice(-15).join('\n');
|
|
941
|
+
} else {
|
|
942
|
+
const raw = runner.getBuffer(workDir);
|
|
943
|
+
const cleaned = raw ? cleanPtyOutput(raw) : '';
|
|
944
|
+
if (cleaned) {
|
|
945
|
+
lastLines = cleaned.split('\n').slice(-15).join('\n');
|
|
946
|
+
}
|
|
947
|
+
}
|
|
838
948
|
|
|
839
949
|
return `<b>${escapeHtml(label)}</b>
|
|
840
950
|
State: <code>${info.state}</code>
|
|
841
951
|
Buffer: <code>${info.bufferSize}</code> bytes
|
|
842
952
|
Elapsed: ${elapsed}
|
|
843
953
|
Live console: ${liveTimer}
|
|
954
|
+
JSONL source: ${hasJsonl}
|
|
844
955
|
PTY log: <code>${info.hasLogStream ? 'writing' : 'off'}</code>
|
|
845
956
|
|
|
846
957
|
<pre>${escapeHtml(lastLines)}</pre>`;
|
|
@@ -880,6 +991,9 @@ const MENU_KEYBOARD = {
|
|
|
880
991
|
[
|
|
881
992
|
{ text: '📜 History', callback_data: '/history' },
|
|
882
993
|
{ text: '🖥 PTY', callback_data: '/pty' },
|
|
994
|
+
{ text: '🏠 Default', callback_data: '/setdefault' },
|
|
995
|
+
],
|
|
996
|
+
[
|
|
883
997
|
{ text: '📖 Help', callback_data: '/help' },
|
|
884
998
|
],
|
|
885
999
|
],
|
|
@@ -897,6 +1011,7 @@ function handleHelp () {
|
|
|
897
1011
|
/clear &project[/branch] — clear queue + reset session
|
|
898
1012
|
/newsession [&project[/branch]] — reset session (keep queue)
|
|
899
1013
|
/projects — list projects
|
|
1014
|
+
/setdefault — change default project
|
|
900
1015
|
/worktrees &project — project worktrees
|
|
901
1016
|
/worktree &project/branch — create worktree
|
|
902
1017
|
/rmworktree &project/branch — remove worktree
|
|
@@ -1014,7 +1129,7 @@ async function mainLoop () {
|
|
|
1014
1129
|
await poller.answerCallbackQuery(msg.callbackQueryId);
|
|
1015
1130
|
}
|
|
1016
1131
|
|
|
1017
|
-
const parsed = parseMessage(msg.text);
|
|
1132
|
+
const parsed = parseMessage(msg.text, getDefaultProject(listenerConfig.projects));
|
|
1018
1133
|
if (!parsed) {
|
|
1019
1134
|
continue;
|
|
1020
1135
|
}
|
|
@@ -1048,6 +1163,7 @@ async function mainLoop () {
|
|
|
1048
1163
|
{ command: 'status', description: 'Status of all projects' },
|
|
1049
1164
|
{ command: 'queue', description: 'Show all queues' },
|
|
1050
1165
|
{ command: 'projects', description: 'List projects' },
|
|
1166
|
+
{ command: 'setdefault', description: 'Change default project' },
|
|
1051
1167
|
{ command: 'history', description: 'Recent task history' },
|
|
1052
1168
|
{ command: 'pty', description: 'PTY session diagnostics' },
|
|
1053
1169
|
{ command: 'help', description: 'Show all commands' },
|
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
* /command args → { type: 'command', cmd, args }
|
|
8
8
|
* &project/branch text → { type: 'task', project, branch, text }
|
|
9
9
|
* &project text → { type: 'task', project, branch: null, text }
|
|
10
|
-
* text → { type: 'task', project:
|
|
10
|
+
* text → { type: 'task', project: <defaultProject>, branch: null, text }
|
|
11
11
|
*
|
|
12
12
|
* Any /word is treated as a command (known or unknown).
|
|
13
13
|
* Project designation uses & prefix: &project or &project/branch.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} text - The message text.
|
|
16
|
+
* @param {string} [defaultProject] - Alias of the default project (used for plain text tasks).
|
|
14
17
|
*/
|
|
15
|
-
export function parseMessage (text) {
|
|
18
|
+
export function parseMessage (text, defaultProject) {
|
|
16
19
|
if (!text || typeof text !== 'string') {
|
|
17
20
|
return null;
|
|
18
21
|
}
|
|
@@ -61,7 +64,7 @@ export function parseMessage (text) {
|
|
|
61
64
|
// Plain text → default project
|
|
62
65
|
return {
|
|
63
66
|
type: 'task',
|
|
64
|
-
project: 'default',
|
|
67
|
+
project: defaultProject || 'default',
|
|
65
68
|
branch: null,
|
|
66
69
|
text: trimmed,
|
|
67
70
|
};
|
package/listener/pty-runner.js
CHANGED
|
@@ -135,12 +135,16 @@ export class PtyRunner extends EventEmitter {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
} else if (type === 'ready') {
|
|
138
|
-
// SessionStart — emit ready event
|
|
138
|
+
// SessionStart — emit ready event, capture sessionId from filename
|
|
139
139
|
this._unlinkSafe(filePath);
|
|
140
|
+
const signalSessionId = f.startsWith('rdy_') ? f.slice(4, -5) : null;
|
|
140
141
|
for (const [workDir, session] of this.sessions) {
|
|
141
142
|
if (this._normalizePath(session.workDir) === this._normalizePath(cwd)) {
|
|
142
143
|
session._lastActivityTime = Date.now();
|
|
143
144
|
session._model = marker.model || '';
|
|
145
|
+
if (signalSessionId && signalSessionId !== 'unknown') {
|
|
146
|
+
session.sessionId = signalSessionId;
|
|
147
|
+
}
|
|
144
148
|
this.emit('ready', workDir, marker);
|
|
145
149
|
break;
|
|
146
150
|
}
|
|
@@ -613,6 +617,14 @@ export class PtyRunner extends EventEmitter {
|
|
|
613
617
|
return session?._buffer || '';
|
|
614
618
|
}
|
|
615
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Get Claude session ID for a workDir (captured from SessionStart hook signal).
|
|
622
|
+
*/
|
|
623
|
+
getSessionId (workDir) {
|
|
624
|
+
const session = this.sessions.get(workDir);
|
|
625
|
+
return session?.sessionId || null;
|
|
626
|
+
}
|
|
627
|
+
|
|
616
628
|
/**
|
|
617
629
|
* Get last tool activity for a workDir (from PostToolUse hook signals).
|
|
618
630
|
*/
|
package/notifier/notifier.js
CHANGED
|
@@ -63,6 +63,7 @@ function loadConfig () {
|
|
|
63
63
|
webhookUrl: '',
|
|
64
64
|
notifyAfterSeconds: 15,
|
|
65
65
|
notifyOnWaiting: false,
|
|
66
|
+
notifyOnPermission: true,
|
|
66
67
|
debug: false,
|
|
67
68
|
};
|
|
68
69
|
|
|
@@ -88,6 +89,9 @@ function loadConfig () {
|
|
|
88
89
|
if (typeof user.notifyOnWaiting === 'boolean') {
|
|
89
90
|
config.notifyOnWaiting = user.notifyOnWaiting;
|
|
90
91
|
}
|
|
92
|
+
if (typeof user.notifyOnPermission === 'boolean') {
|
|
93
|
+
config.notifyOnPermission = user.notifyOnPermission;
|
|
94
|
+
}
|
|
91
95
|
if (typeof user.debug === 'boolean') {
|
|
92
96
|
config.debug = user.debug;
|
|
93
97
|
}
|
|
@@ -122,6 +126,9 @@ function loadConfig () {
|
|
|
122
126
|
if (process.env.CLAUDE_NOTIFY_WAITING !== undefined) {
|
|
123
127
|
config.notifyOnWaiting = process.env.CLAUDE_NOTIFY_WAITING === '1';
|
|
124
128
|
}
|
|
129
|
+
if (process.env.CLAUDE_NOTIFY_ON_PERMISSION !== undefined) {
|
|
130
|
+
config.notifyOnPermission = process.env.CLAUDE_NOTIFY_ON_PERMISSION === '1';
|
|
131
|
+
}
|
|
125
132
|
if (process.env.CLAUDE_NOTIFY_DEBUG !== undefined) {
|
|
126
133
|
config.debug = process.env.CLAUDE_NOTIFY_DEBUG === '1';
|
|
127
134
|
}
|
|
@@ -641,11 +648,10 @@ function getVoicePhrase (duration, project) {
|
|
|
641
648
|
return fn(duration, project || 'unknown');
|
|
642
649
|
}
|
|
643
650
|
|
|
644
|
-
function
|
|
651
|
+
function speakText (config, text) {
|
|
645
652
|
if (!config.voice.enabled) {
|
|
646
653
|
return;
|
|
647
654
|
}
|
|
648
|
-
const text = getVoicePhrase(duration, project);
|
|
649
655
|
try {
|
|
650
656
|
switch (PLATFORM) {
|
|
651
657
|
case 'win32': {
|
|
@@ -672,10 +678,26 @@ function speakResult (config, duration, project) {
|
|
|
672
678
|
}
|
|
673
679
|
}
|
|
674
680
|
} catch (err) {
|
|
675
|
-
debugLog(config, '
|
|
681
|
+
debugLog(config, 'speakText failed:', err.message);
|
|
676
682
|
}
|
|
677
683
|
}
|
|
678
684
|
|
|
685
|
+
function speakResult (config, duration, project) {
|
|
686
|
+
speakText(config, getVoicePhrase(duration, project));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const permissionVoicePhrases = {
|
|
690
|
+
en: (p, tool) => `Claude needs your permission on ${p} for ${tool}`,
|
|
691
|
+
ru: (p, tool) => `Клод ожидает разрешение в проекте ${p} на ${tool}`,
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
function getPermissionVoicePhrase (project, toolName) {
|
|
695
|
+
const locale = Intl.DateTimeFormat().resolvedOptions().locale || 'en';
|
|
696
|
+
const lang = locale.split('-')[0].toLowerCase();
|
|
697
|
+
const fn = permissionVoicePhrases[lang] || permissionVoicePhrases.en;
|
|
698
|
+
return fn(project || 'unknown', toolName || 'unknown');
|
|
699
|
+
}
|
|
700
|
+
|
|
679
701
|
// ----------------------
|
|
680
702
|
// READ HOOK INPUT
|
|
681
703
|
// ----------------------
|
|
@@ -760,7 +782,7 @@ process.stdin.on('end', async () => {
|
|
|
760
782
|
// STOP / NOTIFICATION EVENT
|
|
761
783
|
// ----------------------
|
|
762
784
|
|
|
763
|
-
if (eventType !== 'Stop' && eventType !== 'Notification' && eventType !== 'StopFailure') {
|
|
785
|
+
if (eventType !== 'Stop' && eventType !== 'Notification' && eventType !== 'StopFailure' && eventType !== 'PermissionRequest') {
|
|
764
786
|
process.exit(0);
|
|
765
787
|
}
|
|
766
788
|
|
|
@@ -768,6 +790,10 @@ process.stdin.on('end', async () => {
|
|
|
768
790
|
process.exit(0);
|
|
769
791
|
}
|
|
770
792
|
|
|
793
|
+
if (eventType === 'PermissionRequest' && !config.notifyOnPermission) {
|
|
794
|
+
process.exit(0);
|
|
795
|
+
}
|
|
796
|
+
|
|
771
797
|
let duration = 0;
|
|
772
798
|
const session = state.sessions[sessionId];
|
|
773
799
|
if (session?.start) {
|
|
@@ -778,8 +804,21 @@ process.stdin.on('end', async () => {
|
|
|
778
804
|
process.exit(0);
|
|
779
805
|
}
|
|
780
806
|
|
|
781
|
-
const
|
|
782
|
-
const
|
|
807
|
+
const permToolName = event.tool_name || 'unknown';
|
|
808
|
+
const permDetail = event.tool_input?.file_path || event.tool_input?.command?.slice(0, 80) || '';
|
|
809
|
+
|
|
810
|
+
const statusEmoji = eventType === 'PermissionRequest' ? '🔐' : eventType === 'Notification' ? '⏸' : eventType === 'StopFailure' ? '❌' : '✅';
|
|
811
|
+
|
|
812
|
+
let desktopStatus;
|
|
813
|
+
if (eventType === 'PermissionRequest') {
|
|
814
|
+
desktopStatus = `Permission: ${permToolName}${permDetail ? ` — ${path.basename(permDetail)}` : ''}`;
|
|
815
|
+
} else if (eventType === 'Notification') {
|
|
816
|
+
desktopStatus = 'Waiting';
|
|
817
|
+
} else if (eventType === 'StopFailure') {
|
|
818
|
+
desktopStatus = `Error: ${event.error || 'unknown'}`;
|
|
819
|
+
} else {
|
|
820
|
+
desktopStatus = 'Finished';
|
|
821
|
+
}
|
|
783
822
|
|
|
784
823
|
const branch = getBranch(cwd);
|
|
785
824
|
let label = `/${project}`;
|
|
@@ -794,8 +833,16 @@ process.stdin.on('end', async () => {
|
|
|
794
833
|
const desktopTitle = label;
|
|
795
834
|
const desktopMessage = desktopStatus;
|
|
796
835
|
|
|
797
|
-
let telegramMessage
|
|
798
|
-
|
|
836
|
+
let telegramMessage;
|
|
837
|
+
if (eventType === 'PermissionRequest') {
|
|
838
|
+
telegramMessage = `${statusEmoji} ${labelHtml}\nPermission: <b>${escapeHtml(permToolName)}</b>`;
|
|
839
|
+
if (permDetail) {
|
|
840
|
+
telegramMessage += `\n<code>${escapeHtml(permDetail)}</code>`;
|
|
841
|
+
}
|
|
842
|
+
telegramMessage += `\n(duration: ${duration}s)${triggerLine}`;
|
|
843
|
+
} else {
|
|
844
|
+
telegramMessage = `${statusEmoji} ${labelHtml}\n(duration: ${duration}s)${triggerLine}`;
|
|
845
|
+
}
|
|
799
846
|
|
|
800
847
|
if (config.telegram.includeLastCcMessageInTelegram && event.last_assistant_message) {
|
|
801
848
|
const maxLen = 3500;
|
|
@@ -813,15 +860,24 @@ process.stdin.on('end', async () => {
|
|
|
813
860
|
telegramMessage += debugBlockHtml;
|
|
814
861
|
}
|
|
815
862
|
|
|
816
|
-
|
|
863
|
+
const webhookPayload = {
|
|
817
864
|
title: `${desktopStatus}: ${label}`,
|
|
818
865
|
project,
|
|
819
866
|
branch: branch || undefined,
|
|
820
867
|
duration,
|
|
821
868
|
trigger: eventType,
|
|
822
|
-
voicePhrase: config.voice.enabled ? getVoicePhrase(duration, project) : null,
|
|
823
869
|
hookEvent: event,
|
|
824
|
-
}
|
|
870
|
+
};
|
|
871
|
+
if (eventType === 'PermissionRequest') {
|
|
872
|
+
webhookPayload.toolName = permToolName;
|
|
873
|
+
webhookPayload.toolInput = event.tool_input || {};
|
|
874
|
+
}
|
|
875
|
+
if (config.voice.enabled) {
|
|
876
|
+
webhookPayload.voicePhrase = eventType === 'PermissionRequest'
|
|
877
|
+
? getPermissionVoicePhrase(project, permToolName)
|
|
878
|
+
: getVoicePhrase(duration, project);
|
|
879
|
+
}
|
|
880
|
+
await sendWebhook(config, webhookPayload);
|
|
825
881
|
|
|
826
882
|
state._telegramText = telegramMessage;
|
|
827
883
|
await sendTelegram(config, state);
|
|
@@ -833,5 +889,9 @@ process.stdin.on('end', async () => {
|
|
|
833
889
|
|
|
834
890
|
await sendDesktopNotification(config, desktopTitle, desktopMessage);
|
|
835
891
|
playSound(config);
|
|
836
|
-
|
|
892
|
+
if (eventType === 'PermissionRequest') {
|
|
893
|
+
speakText(config, getPermissionVoicePhrase(project, permToolName));
|
|
894
|
+
} else {
|
|
895
|
+
speakResult(config, duration, project);
|
|
896
|
+
}
|
|
837
897
|
});
|
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.75",
|
|
5
5
|
"description": "Claude Code task-completion notifications: Telegram, desktop notifications (Windows/macOS/Linux), sound, and voice",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|