turnbell 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/dist/index.js +4673 -0
- package/package.json +67 -0
- package/scripts/helper/play-then-say.mjs +83 -0
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "turnbell",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Voice, desktop, and webhook notifications for CLI coding agents (Claude Code, Codex, Gemini, Copilot, Cursor & more) — calls out the moment your agent finishes or needs you.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"notifications",
|
|
8
|
+
"desktop-notifications",
|
|
9
|
+
"text-to-speech",
|
|
10
|
+
"tts",
|
|
11
|
+
"voice",
|
|
12
|
+
"coding-agent",
|
|
13
|
+
"ai-agent",
|
|
14
|
+
"agent-notifications",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"codex",
|
|
17
|
+
"gemini-cli",
|
|
18
|
+
"copilot-cli",
|
|
19
|
+
"cursor",
|
|
20
|
+
"opencode",
|
|
21
|
+
"webhook",
|
|
22
|
+
"ntfy",
|
|
23
|
+
"pushover",
|
|
24
|
+
"slack",
|
|
25
|
+
"developer-tools",
|
|
26
|
+
"productivity",
|
|
27
|
+
"terminal",
|
|
28
|
+
"hooks"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://github.com/paurushrai/turnbell#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/paurushrai/turnbell/issues"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/paurushrai/turnbell.git"
|
|
37
|
+
},
|
|
38
|
+
"author": "Paurush Rai",
|
|
39
|
+
"type": "module",
|
|
40
|
+
"bin": {
|
|
41
|
+
"turnbell": "dist/index.js",
|
|
42
|
+
"hollr": "dist/index.js"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"scripts/helper"
|
|
47
|
+
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"coverage": "vitest run --coverage"
|
|
55
|
+
},
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@clack/prompts": "1.7.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/node": "^26.1.1",
|
|
62
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
63
|
+
"tsup": "^8.5.1",
|
|
64
|
+
"typescript": "^7.0.2",
|
|
65
|
+
"vitest": "^4.1.10"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Blocking helper: play a sound, THEN speak — never concurrently.
|
|
4
|
+
*
|
|
5
|
+
* Spawned detached by src/platform/sequencer.ts (so the calling hook never
|
|
6
|
+
* blocks); this process itself blocks on the sound in sequence, guaranteeing
|
|
7
|
+
* the sound finishes before the voice starts. It also tracks the voice PID in a
|
|
8
|
+
* pidfile so src/core/control.ts can pause/resume/stop the reading in progress.
|
|
9
|
+
*
|
|
10
|
+
* Plain ESM with ZERO imports from src — it ships in the npm package and runs
|
|
11
|
+
* standalone under `node`. The payload (both argv arrays and the pidfile path)
|
|
12
|
+
* is passed as a single JSON argument, since this file cannot re-derive
|
|
13
|
+
* HOLLR_HOME from the source tree.
|
|
14
|
+
*
|
|
15
|
+
* Every path is defensive: any failure must exit 0, never throw, because this
|
|
16
|
+
* runs unattended behind a Claude Code hook.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
20
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const PID_ENCODING = "utf8";
|
|
24
|
+
|
|
25
|
+
/** Record the voice PID so control.ts can find it. Best-effort. */
|
|
26
|
+
function writePid(pidPath, pid) {
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(pidPath, String(pid), PID_ENCODING);
|
|
29
|
+
} catch {
|
|
30
|
+
// PID tracking is best-effort; a write failure just disables control.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Remove the pidfile only if it still names `pid` — guards against clobbering
|
|
36
|
+
* the tracking of a newer reading that took over while this one was ending.
|
|
37
|
+
*/
|
|
38
|
+
function clearPidIfOwned(pidPath, pid) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(pidPath, PID_ENCODING).trim();
|
|
41
|
+
if (raw === String(pid)) {
|
|
42
|
+
unlinkSync(pidPath);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Nothing to clear.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{ soundArgv: string[] | null, voiceArgv: string[] | null, pidPath: string }} payload
|
|
51
|
+
*/
|
|
52
|
+
export function run(payload) {
|
|
53
|
+
try {
|
|
54
|
+
if (Array.isArray(payload.soundArgv)) {
|
|
55
|
+
const [command, ...args] = payload.soundArgv;
|
|
56
|
+
spawnSync(command, args);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(payload.voiceArgv)) {
|
|
59
|
+
const [command, ...args] = payload.voiceArgv;
|
|
60
|
+
const child = spawn(command, args);
|
|
61
|
+
child.on("error", () => {});
|
|
62
|
+
if (typeof child.pid === "number") {
|
|
63
|
+
writePid(payload.pidPath, child.pid);
|
|
64
|
+
child.on("close", () => clearPidIfOwned(payload.pidPath, child.pid));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Never propagate — a missing binary or bad payload must not crash the hook.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isEntry() {
|
|
73
|
+
const entry = process.argv[1];
|
|
74
|
+
return entry !== undefined && entry === fileURLToPath(import.meta.url);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isEntry()) {
|
|
78
|
+
try {
|
|
79
|
+
run(JSON.parse(process.argv[2]));
|
|
80
|
+
} catch {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
}
|