code-alarm 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -2
- package/dist/code-alarm.mjs +1102 -0
- package/package.json +33 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yevhen Mishchenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,124 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Code Alarm</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<b>Start a long agent task, walk away, and get woken up when it's done.</b><br>
|
|
5
|
+
A real alarm on your phone when Claude Code or Codex finishes or needs you.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://github.com/Draganoider/code-alarm/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Draganoider/code-alarm/actions/workflows/ci.yml/badge.svg?branch=main"></a>
|
|
10
|
+
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
11
|
+
<img alt="Agents: Claude Code and Codex" src="https://img.shields.io/badge/agents-Claude%20Code%20%7C%20Codex-8A63D2">
|
|
12
|
+
<img alt="Node 22+" src="https://img.shields.io/badge/node-%E2%89%A522-339933">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
Coding agents now work for an hour or more on their own. Their phone apps will *notify* you when
|
|
18
|
+
they're done, but Do Not Disturb swallows a notification and it rings once. **Code Alarm rings like
|
|
19
|
+
an alarm**, until you dismiss it, and only for work you armed.
|
|
20
|
+
|
|
21
|
+
- **One command to arm:** `/code-alarm:alarm` in Claude Code, or ask Codex to use the code-alarm
|
|
22
|
+
skill. Sessions you didn't arm stay silent.
|
|
23
|
+
- **Knows why it stopped:** *done*, *needs you* (a question or a permission prompt), *failed*, or
|
|
24
|
+
*stopped*.
|
|
25
|
+
- **Nothing leaves your computer** except the event type and the project folder's name: no prompts,
|
|
26
|
+
code, paths or transcripts ([protocol](docs/protocol.md)).
|
|
27
|
+
- **Never slows your agent down:** hooks return at once and send in the background. A network
|
|
28
|
+
problem can't stall a session.
|
|
29
|
+
- **Free:** it rings your phone through the [ntfy](https://ntfy.sh) app today. A dedicated Code
|
|
30
|
+
Alarm Android app is coming.
|
|
31
|
+
|
|
32
|
+
## Set up (5 minutes)
|
|
33
|
+
|
|
34
|
+
You need **Node.js 22 or newer** on the computer that runs your agent.
|
|
35
|
+
|
|
36
|
+
**1. Create your private alarm channel:**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx code-alarm init
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It prints a long random ntfy topic. Treat it like a password: anyone who knows it can ring your
|
|
43
|
+
phone.
|
|
44
|
+
|
|
45
|
+
**2. On your phone:** install **ntfy** ([Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
|
46
|
+
[F-Droid](https://f-droid.org/packages/io.heckel.ntfy/), [App Store](https://apps.apple.com/app/ntfy/id1625396347))
|
|
47
|
+
and subscribe to that topic. In the subscription's settings, turn on **keep alerting for the
|
|
48
|
+
highest priority** and the **Do Not Disturb override**. Then check it:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx code-alarm test
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**3. Connect your agent.**
|
|
55
|
+
|
|
56
|
+
Claude Code:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
claude plugin marketplace add Draganoider/code-alarm
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
claude plugin install code-alarm@code-alarm
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Codex (adds user-level hooks and a skill; open Codex afterwards and **trust the new hooks**):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx code-alarm install codex
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Use
|
|
73
|
+
|
|
74
|
+
Before a long task, arm it:
|
|
75
|
+
|
|
76
|
+
| Agent | How |
|
|
77
|
+
|---|---|
|
|
78
|
+
| Claude Code | `/code-alarm:alarm`, then give it the task (or say "wake me when it's done") |
|
|
79
|
+
| Codex | "Use the code-alarm skill and …", or "wake me when it's done" |
|
|
80
|
+
| Any terminal | `code-alarm arm` in the project folder; `code-alarm nap 2h` arms every session for 2 hours |
|
|
81
|
+
|
|
82
|
+
**Prefer a plain notification?** Say *quiet*: `/code-alarm:alarm quiet …`, "quietly notify me
|
|
83
|
+
when done", or `code-alarm arm --notify`. You get one normal notification, which pops up with sound
|
|
84
|
+
and vibration and follows your phone's sound settings, instead of the repeating alarm.
|
|
85
|
+
|
|
86
|
+
The agent ends its work with `done`, `needs-you` or `failed`, and your phone rings with that
|
|
87
|
+
reason. If it forgets, the alarm still rings when it stops, labelled *stopped*.
|
|
88
|
+
|
|
89
|
+
`code-alarm status` shows what's set up, what's armed and the last alerts. `code-alarm disarm`
|
|
90
|
+
cancels arming.
|
|
91
|
+
|
|
92
|
+
## How it works
|
|
93
|
+
|
|
94
|
+
```mermaid
|
|
95
|
+
sequenceDiagram
|
|
96
|
+
actor You
|
|
97
|
+
participant Agent as Claude Code / Codex
|
|
98
|
+
participant CLI as code-alarm (hooks)
|
|
99
|
+
participant Phone as ntfy on your phone
|
|
100
|
+
You->>Agent: /code-alarm:alarm + the task
|
|
101
|
+
Agent->>CLI: code-alarm arm
|
|
102
|
+
Note over Agent: works for minutes or hours
|
|
103
|
+
Agent->>CLI: code-alarm done (or the Stop hook)
|
|
104
|
+
CLI-->>Phone: {kind, folder name}, max priority
|
|
105
|
+
Phone->>You: rings until you dismiss it
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Details: [event protocol](docs/protocol.md) · [agent integration notes](docs/agents.md).
|
|
109
|
+
|
|
110
|
+
## Develop
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd cli
|
|
114
|
+
npm install
|
|
115
|
+
npm run check
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`npm run check` type-checks, runs the tests, and builds the bundle into `cli/dist/` and the Claude
|
|
119
|
+
Code plugin. The bundled files are committed, because plugins install straight from the repository.
|
|
120
|
+
To try your working copy in Claude Code: `claude --plugin-dir plugins/claude-code`.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
[MIT](LICENSE). Not affiliated with Anthropic or OpenAI.
|
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// code-alarm, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit.
|
|
3
|
+
|
|
4
|
+
// src/main.ts
|
|
5
|
+
import { homedir as homedir2 } from "node:os";
|
|
6
|
+
import { resolve as resolve3 } from "node:path";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
// src/config.ts
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// src/fsutil.ts
|
|
15
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
function writeFileAtomic(path, data, mode) {
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
19
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
20
|
+
writeFileSync(temp, data, mode === void 0 ? void 0 : { mode });
|
|
21
|
+
renameSync(temp, path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/config.ts
|
|
25
|
+
var ConfigError = class extends Error {
|
|
26
|
+
};
|
|
27
|
+
var DEFAULT_NTFY_SERVER = "https://ntfy.sh";
|
|
28
|
+
function configPath(home) {
|
|
29
|
+
return join(home, "config.json");
|
|
30
|
+
}
|
|
31
|
+
function loadConfig(home) {
|
|
32
|
+
const path = configPath(home);
|
|
33
|
+
if (!existsSync(path)) return null;
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
throw new ConfigError(`${path} isn't valid JSON. Fix it, or run \`code-alarm init --force\` to start over.`);
|
|
39
|
+
}
|
|
40
|
+
const config = raw;
|
|
41
|
+
const ntfy = config.ntfy;
|
|
42
|
+
if (config.transport !== "ntfy" || !ntfy || typeof ntfy.server !== "string" || typeof ntfy.topic !== "string") {
|
|
43
|
+
throw new ConfigError(`${path} is missing its ntfy settings. Run \`code-alarm init --force\`.`);
|
|
44
|
+
}
|
|
45
|
+
return { version: 1, transport: "ntfy", ntfy: { server: ntfy.server, topic: ntfy.topic, ...ntfy.token ? { token: ntfy.token } : {} } };
|
|
46
|
+
}
|
|
47
|
+
function saveConfig(home, config) {
|
|
48
|
+
writeFileAtomic(configPath(home), JSON.stringify(config, null, 2) + "\n", 384);
|
|
49
|
+
}
|
|
50
|
+
function newTopic() {
|
|
51
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
52
|
+
const bytes = randomBytes(24);
|
|
53
|
+
let suffix = "";
|
|
54
|
+
for (let i = 0; i < 24; i++) suffix += alphabet[bytes[i] % alphabet.length];
|
|
55
|
+
return `code-alarm-${suffix}`;
|
|
56
|
+
}
|
|
57
|
+
function isValidTopic(topic) {
|
|
58
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(topic);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/deliver.ts
|
|
62
|
+
import { spawn } from "node:child_process";
|
|
63
|
+
import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
64
|
+
import { join as join2 } from "node:path";
|
|
65
|
+
|
|
66
|
+
// src/events.ts
|
|
67
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
68
|
+
var EVENT_FIELDS = ["v", "id", "kind", "level", "label", "agent", "sent_at", "expires_at"];
|
|
69
|
+
var KINDS = ["done", "needs_input", "failed", "stopped", "test"];
|
|
70
|
+
var DEFAULT_TTL_MS = 30 * 6e4;
|
|
71
|
+
var MAX_LABEL = 40;
|
|
72
|
+
function labelFor(dir) {
|
|
73
|
+
const name = (dir.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "").replace(/[^A-Za-z0-9._ -]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-. ]+|[-. ]+$/g, "").slice(0, MAX_LABEL).trim();
|
|
74
|
+
return name || "project";
|
|
75
|
+
}
|
|
76
|
+
function eventId(parts) {
|
|
77
|
+
if (!parts || parts.some((part) => !part)) return randomUUID();
|
|
78
|
+
const hex = createHash("sha256").update(parts.join("|")).digest("hex");
|
|
79
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
80
|
+
}
|
|
81
|
+
function buildEvent(args) {
|
|
82
|
+
return {
|
|
83
|
+
v: 1,
|
|
84
|
+
id: eventId(args.idParts),
|
|
85
|
+
kind: args.kind,
|
|
86
|
+
level: args.level ?? "alarm",
|
|
87
|
+
label: args.label.slice(0, MAX_LABEL),
|
|
88
|
+
agent: args.agent,
|
|
89
|
+
sent_at: args.now,
|
|
90
|
+
expires_at: args.now + (args.ttlMs ?? DEFAULT_TTL_MS)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function agentName(agent) {
|
|
94
|
+
return agent === "claude-code" ? "Claude Code" : agent === "codex" ? "Codex" : "Your task";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/ntfy.ts
|
|
98
|
+
var TEXT = {
|
|
99
|
+
done: { title: "Done", message: (a) => `${a} finished the task.`, tags: ["white_check_mark"] },
|
|
100
|
+
needs_input: { title: "Needs you", message: (a) => `${a} is waiting for your answer or approval.`, tags: ["raising_hand"] },
|
|
101
|
+
failed: { title: "Failed", message: (a) => `${a} stopped on an error.`, tags: ["x"] },
|
|
102
|
+
stopped: { title: "Stopped", message: (a) => `${a} stopped. It may be done, or waiting for you.`, tags: ["warning"] },
|
|
103
|
+
test: { title: "Test alarm", message: () => "If your phone rang loudly, Code Alarm is set up.", tags: ["bell"] }
|
|
104
|
+
};
|
|
105
|
+
function ntfyRequest(event, config) {
|
|
106
|
+
const text = TEXT[event.kind];
|
|
107
|
+
const headers = { "content-type": "application/json" };
|
|
108
|
+
if (config.token) headers.authorization = `Bearer ${config.token}`;
|
|
109
|
+
return {
|
|
110
|
+
url: `${config.server.replace(/\/+$/, "")}/`,
|
|
111
|
+
init: {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers,
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
topic: config.topic,
|
|
116
|
+
title: event.kind === "test" ? text.title : `${text.title}: ${event.label}`,
|
|
117
|
+
message: text.message(agentName(event.agent)),
|
|
118
|
+
// 5 ("max") is the alarm: the ntfy app keeps alerting until dismissed, through Do Not
|
|
119
|
+
// Disturb if the subscription allows it. 4 ("high") is a normal notification that pops up
|
|
120
|
+
// with sound and vibration, once. 3 ("default") often arrives silently, without a pop-up.
|
|
121
|
+
priority: event.level === "notify" ? 4 : 5,
|
|
122
|
+
tags: text.tags
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function sendNtfy(event, config, fetchImpl = fetch, timeoutMs = 4e3) {
|
|
128
|
+
const { url, init } = ntfyRequest(event, config);
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
131
|
+
return res.ok ? { ok: true, detail: `HTTP ${res.status}` } : { ok: false, detail: `HTTP ${res.status}` };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return { ok: false, detail: e instanceof Error ? e.name : "error" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/deliver.ts
|
|
138
|
+
var logPath = (home) => join2(home, "log.txt");
|
|
139
|
+
function logLine(home, line) {
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync2(home, { recursive: true });
|
|
142
|
+
const path = logPath(home);
|
|
143
|
+
appendFileSync(path, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
144
|
+
`);
|
|
145
|
+
if (statSync(path).size > 64e3) {
|
|
146
|
+
const lines = readFileSync2(path, "utf8").trimEnd().split("\n");
|
|
147
|
+
writeFileAtomic(path, lines.slice(-200).join("\n") + "\n");
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function recentLog(home, count) {
|
|
153
|
+
try {
|
|
154
|
+
return readFileSync2(logPath(home), "utf8").trimEnd().split("\n").slice(-count);
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function sendNow(home, event, fetchImpl) {
|
|
160
|
+
const config = loadConfig(home);
|
|
161
|
+
if (!config) return { ok: false, detail: "not set up: run `code-alarm init`" };
|
|
162
|
+
const result = await sendNtfy(event, config.ntfy, fetchImpl);
|
|
163
|
+
logLine(home, `${event.kind}/${event.level} ${event.agent} "${event.label}" ${result.ok ? "sent" : "FAILED"} (${result.detail})`);
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
function sendDetached(event, script = process.argv[1]) {
|
|
167
|
+
const payload = Buffer.from(JSON.stringify(event)).toString("base64url");
|
|
168
|
+
runDetached(script, ["__send", payload]);
|
|
169
|
+
}
|
|
170
|
+
function waitDetached(id, at, script = process.argv[1]) {
|
|
171
|
+
runDetached(script, ["__wait", id, String(at)]);
|
|
172
|
+
}
|
|
173
|
+
function runDetached(script, args) {
|
|
174
|
+
if (!script) return;
|
|
175
|
+
const child = spawn(process.execPath, [script, ...args], { detached: true, stdio: "ignore", windowsHide: true });
|
|
176
|
+
child.on("error", () => {
|
|
177
|
+
});
|
|
178
|
+
child.unref();
|
|
179
|
+
}
|
|
180
|
+
function decodeEvent(arg) {
|
|
181
|
+
try {
|
|
182
|
+
const event = JSON.parse(Buffer.from(arg, "base64url").toString("utf8"));
|
|
183
|
+
const keys = Object.keys(event);
|
|
184
|
+
const allowed = new Set(EVENT_FIELDS);
|
|
185
|
+
if (keys.some((key) => !allowed.has(key))) return null;
|
|
186
|
+
if (event.v !== 1 || typeof event.id !== "string" || typeof event.label !== "string") return null;
|
|
187
|
+
if (!KINDS.includes(event.kind)) return null;
|
|
188
|
+
if (typeof event.sent_at !== "number" || typeof event.expires_at !== "number") return null;
|
|
189
|
+
return event;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/duration.ts
|
|
196
|
+
var HOUR = 36e5;
|
|
197
|
+
var MINUTE = 6e4;
|
|
198
|
+
var MAX_ARM_MS = 24 * HOUR;
|
|
199
|
+
function parseDuration(text) {
|
|
200
|
+
const trimmed = text.trim().toLowerCase();
|
|
201
|
+
const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(trimmed);
|
|
202
|
+
if (!match || trimmed === "") return null;
|
|
203
|
+
const [, h, m, s] = match;
|
|
204
|
+
const ms = Number(h ?? 0) * HOUR + Number(m ?? 0) * MINUTE + Number(s ?? 0) * 1e3;
|
|
205
|
+
return ms > 0 ? ms : null;
|
|
206
|
+
}
|
|
207
|
+
function formatDuration(ms) {
|
|
208
|
+
if (ms < MINUTE) return `${Math.max(0, Math.round(ms / 1e3))}s`;
|
|
209
|
+
const h = Math.floor(ms / HOUR);
|
|
210
|
+
const m = Math.round(ms % HOUR / MINUTE);
|
|
211
|
+
if (h === 0) return `${m}m`;
|
|
212
|
+
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/hook.ts
|
|
216
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
217
|
+
import { resolve as resolve2 } from "node:path";
|
|
218
|
+
|
|
219
|
+
// src/state.ts
|
|
220
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
221
|
+
import { closeSync, existsSync as existsSync2, linkSync, mkdirSync as mkdirSync3, openSync, readdirSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, statSync as statSync2 } from "node:fs";
|
|
222
|
+
import { isAbsolute, join as join3, relative, resolve } from "node:path";
|
|
223
|
+
var NAP_KEY = "nap";
|
|
224
|
+
function normalize(dir, platform = process.platform) {
|
|
225
|
+
const full = resolve(dir);
|
|
226
|
+
return platform === "win32" ? full.toLowerCase() : full;
|
|
227
|
+
}
|
|
228
|
+
function keyFor(dir) {
|
|
229
|
+
return createHash2("sha256").update(normalize(dir)).digest("hex").slice(0, 16);
|
|
230
|
+
}
|
|
231
|
+
var armedDir = (home) => join3(home, "armed");
|
|
232
|
+
var signalFile = (home, key) => join3(home, "signaled", `${key}.json`);
|
|
233
|
+
var sentFile = (home, key, kind) => join3(home, "sent", `${key}-${kind}.json`);
|
|
234
|
+
function armProject(home, dir, until, now, level = "alarm") {
|
|
235
|
+
const arming = { kind: "project", level, dir: resolve(dir), armedAt: now, until };
|
|
236
|
+
const key = keyFor(dir);
|
|
237
|
+
writeFileAtomic(join3(armedDir(home), `${key}.json`), JSON.stringify(arming));
|
|
238
|
+
startFresh(home, key);
|
|
239
|
+
return { ...arming, key };
|
|
240
|
+
}
|
|
241
|
+
function armNap(home, until, now, level = "alarm") {
|
|
242
|
+
const arming = { kind: "nap", level, armedAt: now, until };
|
|
243
|
+
writeFileAtomic(join3(armedDir(home), `${NAP_KEY}.json`), JSON.stringify(arming));
|
|
244
|
+
startFresh(home, NAP_KEY);
|
|
245
|
+
return { ...arming, key: NAP_KEY };
|
|
246
|
+
}
|
|
247
|
+
function startFresh(home, key) {
|
|
248
|
+
clearSignaled(home, key);
|
|
249
|
+
const dir = join3(home, "sent");
|
|
250
|
+
if (!existsSync2(dir)) return;
|
|
251
|
+
for (const file of readdirSync(dir)) if (file.startsWith(`${key}-`)) rmSync(join3(dir, file), { force: true });
|
|
252
|
+
}
|
|
253
|
+
function listArmings(home, now) {
|
|
254
|
+
const dir = armedDir(home);
|
|
255
|
+
if (!existsSync2(dir)) return [];
|
|
256
|
+
const found = [];
|
|
257
|
+
for (const file of readdirSync(dir)) {
|
|
258
|
+
if (!file.endsWith(".json")) continue;
|
|
259
|
+
const path = join3(dir, file);
|
|
260
|
+
try {
|
|
261
|
+
const arming = JSON.parse(readFileSync3(path, "utf8"));
|
|
262
|
+
if (typeof arming.until !== "number" || arming.until <= now) {
|
|
263
|
+
rmSync(path, { force: true });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
found.push({ ...arming, key: file.slice(0, -".json".length) });
|
|
267
|
+
} catch {
|
|
268
|
+
rmSync(path, { force: true });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return found;
|
|
272
|
+
}
|
|
273
|
+
function contains(parent, child) {
|
|
274
|
+
const rel = relative(normalize(parent), normalize(child));
|
|
275
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
276
|
+
}
|
|
277
|
+
function findArming(home, cwd, now) {
|
|
278
|
+
const armings = listArmings(home, now);
|
|
279
|
+
let best = null;
|
|
280
|
+
for (const arming of armings) {
|
|
281
|
+
if (arming.kind !== "project" || !arming.dir || !contains(arming.dir, cwd)) continue;
|
|
282
|
+
if (!best || (best.dir?.length ?? 0) < arming.dir.length) best = arming;
|
|
283
|
+
}
|
|
284
|
+
return best ?? armings.find((arming) => arming.kind === "nap") ?? null;
|
|
285
|
+
}
|
|
286
|
+
function claim(home, arming) {
|
|
287
|
+
if (arming.kind !== "project") return true;
|
|
288
|
+
const path = join3(armedDir(home), `${arming.key}.json`);
|
|
289
|
+
const claimed = `${path}.${process.pid}.${Date.now()}.claimed`;
|
|
290
|
+
try {
|
|
291
|
+
renameSync2(path, claimed);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
if (e.code === "ENOENT") return false;
|
|
294
|
+
rmSync(path, { force: true });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const taken = JSON.parse(readFileSync3(claimed, "utf8"));
|
|
299
|
+
if (taken.armedAt !== arming.armedAt || taken.until !== arming.until) {
|
|
300
|
+
try {
|
|
301
|
+
linkSync(claimed, path);
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
304
|
+
rmSync(claimed, { force: true });
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
rmSync(claimed, { force: true });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
function claimEvent(home, id, now) {
|
|
313
|
+
const dir = join3(home, "events");
|
|
314
|
+
try {
|
|
315
|
+
mkdirSync3(dir, { recursive: true });
|
|
316
|
+
for (const file of readdirSync(dir)) {
|
|
317
|
+
try {
|
|
318
|
+
const path = join3(dir, file);
|
|
319
|
+
if (now - statSync2(path).mtimeMs > 24 * 60 * 6e4) rmSync(path, { force: true });
|
|
320
|
+
} catch {
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
closeSync(openSync(join3(dir, id), "wx"));
|
|
324
|
+
return true;
|
|
325
|
+
} catch (e) {
|
|
326
|
+
return e.code !== "EEXIST";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function disarmProject(home, dir) {
|
|
330
|
+
const path = join3(armedDir(home), `${keyFor(dir)}.json`);
|
|
331
|
+
const existed = existsSync2(path);
|
|
332
|
+
rmSync(path, { force: true });
|
|
333
|
+
return existed;
|
|
334
|
+
}
|
|
335
|
+
function disarmAll(home) {
|
|
336
|
+
const dir = armedDir(home);
|
|
337
|
+
if (!existsSync2(dir)) return 0;
|
|
338
|
+
const files = readdirSync(dir).filter((file) => file.endsWith(".json"));
|
|
339
|
+
for (const file of files) rmSync(join3(dir, file), { force: true });
|
|
340
|
+
return files.length;
|
|
341
|
+
}
|
|
342
|
+
function signalKey(armingKey, sessionId) {
|
|
343
|
+
return sessionId ? `session-${createHash2("sha256").update(sessionId).digest("hex").slice(0, 16)}` : armingKey;
|
|
344
|
+
}
|
|
345
|
+
function markSignaled(home, key, now, kind) {
|
|
346
|
+
writeFileAtomic(signalFile(home, key), JSON.stringify({ at: now, kind }));
|
|
347
|
+
}
|
|
348
|
+
function isSignaled(home, key) {
|
|
349
|
+
return existsSync2(signalFile(home, key));
|
|
350
|
+
}
|
|
351
|
+
function signaledKind(home, key) {
|
|
352
|
+
try {
|
|
353
|
+
const { kind } = JSON.parse(readFileSync3(signalFile(home, key), "utf8"));
|
|
354
|
+
return kind ?? null;
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function clearSignaled(home, key) {
|
|
360
|
+
rmSync(signalFile(home, key), { force: true });
|
|
361
|
+
}
|
|
362
|
+
var pendingFile = (home, id) => join3(home, "pending", `${id}.json`);
|
|
363
|
+
var sessionPrefix = (sessionId) => `${signalKey("", sessionId)}-`;
|
|
364
|
+
function pendingId(sessionId, requestHash) {
|
|
365
|
+
return `${sessionPrefix(sessionId)}${requestHash}`;
|
|
366
|
+
}
|
|
367
|
+
function recordPending(home, id, pending) {
|
|
368
|
+
writeFileAtomic(pendingFile(home, id), JSON.stringify(pending));
|
|
369
|
+
}
|
|
370
|
+
function takePending(home, id, at) {
|
|
371
|
+
const path = pendingFile(home, id);
|
|
372
|
+
try {
|
|
373
|
+
const pending = JSON.parse(readFileSync3(path, "utf8"));
|
|
374
|
+
if (pending.at !== at) return null;
|
|
375
|
+
rmSync(path, { force: true });
|
|
376
|
+
return pending;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function clearPending(home, id) {
|
|
382
|
+
const path = pendingFile(home, id);
|
|
383
|
+
const existed = existsSync2(path);
|
|
384
|
+
rmSync(path, { force: true });
|
|
385
|
+
return existed;
|
|
386
|
+
}
|
|
387
|
+
function clearSessionPending(home, sessionId) {
|
|
388
|
+
const dir = join3(home, "pending");
|
|
389
|
+
if (!existsSync2(dir)) return;
|
|
390
|
+
const prefix = sessionPrefix(sessionId);
|
|
391
|
+
for (const file of readdirSync(dir)) if (file.startsWith(prefix)) rmSync(join3(dir, file), { force: true });
|
|
392
|
+
}
|
|
393
|
+
function rateLimited(home, key, kind, now, windowMs) {
|
|
394
|
+
const path = sentFile(home, key, kind);
|
|
395
|
+
try {
|
|
396
|
+
const { at } = JSON.parse(readFileSync3(path, "utf8"));
|
|
397
|
+
if (typeof at === "number" && now - at < windowMs) return true;
|
|
398
|
+
} catch {
|
|
399
|
+
}
|
|
400
|
+
mkdirSync3(join3(home, "sent"), { recursive: true });
|
|
401
|
+
writeFileAtomic(path, JSON.stringify({ at: now }));
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/hook.ts
|
|
406
|
+
var NEEDS_INPUT_WINDOW_MS = 2 * 6e4;
|
|
407
|
+
var PENDING_WAIT_MS = 45e3;
|
|
408
|
+
var NEEDS_INPUT = { type: "send", kind: "needs_input", fallback: false, consume: false, rateLimitMs: NEEDS_INPUT_WINDOW_MS };
|
|
409
|
+
var ENDS_WAITING = /* @__PURE__ */ new Set(["Stop", "StopFailure", "UserPromptSubmit"]);
|
|
410
|
+
var str = (value) => typeof value === "string" && value !== "" ? value : void 0;
|
|
411
|
+
function parseHookInput(raw) {
|
|
412
|
+
try {
|
|
413
|
+
const data = JSON.parse(raw);
|
|
414
|
+
const event = str(data.hook_event_name);
|
|
415
|
+
if (!event) return null;
|
|
416
|
+
const toolInput = data.tool_input && typeof data.tool_input === "object" ? data.tool_input : {};
|
|
417
|
+
const command = str(toolInput.command);
|
|
418
|
+
const request = command ?? (data.tool_input === void 0 ? void 0 : JSON.stringify(data.tool_input));
|
|
419
|
+
return {
|
|
420
|
+
event,
|
|
421
|
+
sessionId: str(data.session_id),
|
|
422
|
+
cwd: str(data.cwd),
|
|
423
|
+
turnId: str(data.turn_id),
|
|
424
|
+
promptId: str(data.prompt_id),
|
|
425
|
+
command,
|
|
426
|
+
requestHash: request === void 0 ? void 0 : createHash3("sha256").update(request).digest("hex").slice(0, 16)
|
|
427
|
+
};
|
|
428
|
+
} catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
var SUBCOMMANDS = /* @__PURE__ */ new Set(["arm", "disarm", "nap", "status", "test", "init", "install", "uninstall", "done", "needs-you", "failed", "help", "version"]);
|
|
433
|
+
var SIGNALS = /* @__PURE__ */ new Set(["done", "needs-you", "failed"]);
|
|
434
|
+
var SHELL = /^(?:pwsh|powershell|bash|sh|zsh|cmd)(?:\.exe)?$/;
|
|
435
|
+
var SCRIPT_FLAG = /^[-/](?:c|lc|command)$/i;
|
|
436
|
+
var CHANGE_DIR = /^(?:cd|chdir|pushd|set-location|sl)$/;
|
|
437
|
+
var EXPANSION = /\$\(|`|[<>]\(/;
|
|
438
|
+
var basename = (word) => (word.split(/[\\/]/).pop() ?? "").toLowerCase();
|
|
439
|
+
function simpleCommands(line) {
|
|
440
|
+
const commands = [];
|
|
441
|
+
let words = [];
|
|
442
|
+
let word = "";
|
|
443
|
+
let inWord = false;
|
|
444
|
+
let quote = null;
|
|
445
|
+
const endWord = () => {
|
|
446
|
+
if (inWord && !/^\d*[<>]/.test(word)) words.push(word);
|
|
447
|
+
word = "";
|
|
448
|
+
inWord = false;
|
|
449
|
+
};
|
|
450
|
+
const endCommand = () => {
|
|
451
|
+
endWord();
|
|
452
|
+
if (words.length > 0) commands.push(words);
|
|
453
|
+
words = [];
|
|
454
|
+
};
|
|
455
|
+
for (let i = 0; i < line.length; i++) {
|
|
456
|
+
const c = line[i];
|
|
457
|
+
if (quote) {
|
|
458
|
+
if (c === quote) quote = null;
|
|
459
|
+
else if (c === "\\" && quote === '"' && (line[i + 1] === '"' || line[i + 1] === "\\")) word += line[++i];
|
|
460
|
+
else word += c;
|
|
461
|
+
} else if (c === '"' || c === "'") {
|
|
462
|
+
quote = c;
|
|
463
|
+
inWord = true;
|
|
464
|
+
} else if (c === "&" && word.endsWith(">")) {
|
|
465
|
+
word += c;
|
|
466
|
+
} else if (c === "(" || c === ")") {
|
|
467
|
+
return null;
|
|
468
|
+
} else if (";&|\n\r".includes(c)) {
|
|
469
|
+
endCommand();
|
|
470
|
+
} else if (/\s/.test(c)) {
|
|
471
|
+
endWord();
|
|
472
|
+
} else {
|
|
473
|
+
word += c;
|
|
474
|
+
inWord = true;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
endCommand();
|
|
478
|
+
return commands;
|
|
479
|
+
}
|
|
480
|
+
function ownInWords(words, cwd) {
|
|
481
|
+
const first = basename(words[0]);
|
|
482
|
+
let i = 0;
|
|
483
|
+
if (/^npx(?:\.cmd)?$/.test(first)) {
|
|
484
|
+
i++;
|
|
485
|
+
while (words[i]?.startsWith("-")) i++;
|
|
486
|
+
} else if (/^node(?:\.exe)?$/.test(first)) {
|
|
487
|
+
i++;
|
|
488
|
+
}
|
|
489
|
+
if (!words[i] || !/^code-alarm(?:\.mjs|\.cmd|\.ps1)?$/.test(basename(words[i]))) return null;
|
|
490
|
+
const sub = words[i + 1];
|
|
491
|
+
return sub && SUBCOMMANDS.has(sub) ? { sub, cwd } : null;
|
|
492
|
+
}
|
|
493
|
+
function ownInLine(line, cwd, depth) {
|
|
494
|
+
const commands = depth > 3 || EXPANSION.test(line) ? null : simpleCommands(line);
|
|
495
|
+
if (!commands) return null;
|
|
496
|
+
let own = null;
|
|
497
|
+
let dir = cwd;
|
|
498
|
+
for (const words of commands) {
|
|
499
|
+
const first = basename(words[0]);
|
|
500
|
+
if (CHANGE_DIR.test(first)) {
|
|
501
|
+
const args = words.slice(1).filter((word) => !/^-(?:path|literalpath)$/i.test(word));
|
|
502
|
+
if (args.length !== 1 || /[$%*?]|^~/.test(args[0]) || args[0] === "-") return null;
|
|
503
|
+
dir = resolve2(dir, args[0]);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
let found;
|
|
507
|
+
if (SHELL.test(first)) {
|
|
508
|
+
const flag = words.findIndex((word, i) => i > 0 && SCRIPT_FLAG.test(word));
|
|
509
|
+
const script = flag > 0 ? words.slice(flag + 1) : [];
|
|
510
|
+
const inner = script.length === 1 ? script[0] : script.map((word) => /\s/.test(word) ? `"${word}"` : word).join(" ");
|
|
511
|
+
found = script.length > 0 ? ownInLine(inner, dir, depth + 1) : null;
|
|
512
|
+
} else {
|
|
513
|
+
found = ownInWords(words, dir);
|
|
514
|
+
}
|
|
515
|
+
if (!found) return null;
|
|
516
|
+
if (!own || !SIGNALS.has(own.sub) && SIGNALS.has(found.sub)) own = found;
|
|
517
|
+
}
|
|
518
|
+
return own;
|
|
519
|
+
}
|
|
520
|
+
function ownCommand(command, cwd) {
|
|
521
|
+
return command ? ownInLine(command, resolve2(cwd), 0) : null;
|
|
522
|
+
}
|
|
523
|
+
function actionFor(agent, input, cwd) {
|
|
524
|
+
switch (input.event) {
|
|
525
|
+
case "Stop":
|
|
526
|
+
return { type: "send", kind: "stopped", fallback: true, consume: true };
|
|
527
|
+
case "StopFailure":
|
|
528
|
+
return { type: "send", kind: "failed", fallback: false, consume: true };
|
|
529
|
+
case "PermissionRequest": {
|
|
530
|
+
const own = ownCommand(input.command, cwd);
|
|
531
|
+
if (own?.sub === "done") return { type: "signal", kind: "done", cwd: own.cwd };
|
|
532
|
+
if (own?.sub === "needs-you") return { type: "signal", kind: "needs_input", cwd: own.cwd };
|
|
533
|
+
if (own?.sub === "failed") return { type: "signal", kind: "failed", cwd: own.cwd };
|
|
534
|
+
if (own) return { type: "none" };
|
|
535
|
+
return agent === "codex" ? { type: "await-answer" } : NEEDS_INPUT;
|
|
536
|
+
}
|
|
537
|
+
case "PostToolUse":
|
|
538
|
+
return agent === "codex" ? { type: "tool-ran" } : { type: "none" };
|
|
539
|
+
case "UserPromptSubmit":
|
|
540
|
+
return { type: "clear-signal" };
|
|
541
|
+
default:
|
|
542
|
+
return { type: "none" };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
var agentOf = (agent) => agent === "claude" ? "claude-code" : "codex";
|
|
546
|
+
async function runHook(agent, raw, deps) {
|
|
547
|
+
try {
|
|
548
|
+
const input = parseHookInput(raw);
|
|
549
|
+
if (!input) return "";
|
|
550
|
+
const cwd = input.cwd ?? deps.cwd();
|
|
551
|
+
if (input.sessionId && ENDS_WAITING.has(input.event)) {
|
|
552
|
+
try {
|
|
553
|
+
clearSessionPending(deps.home, input.sessionId);
|
|
554
|
+
} catch {
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const action = actionFor(agent, input, cwd);
|
|
558
|
+
if (action.type === "none") return "";
|
|
559
|
+
if (action.type === "signal") {
|
|
560
|
+
const result = await sendSignal(action.kind, agentOf(agent), action.cwd, deps, input.sessionId);
|
|
561
|
+
return result.sent ? `hook ${agent} ${input.event}: ${action.kind} signal sent while awaiting approval` : "";
|
|
562
|
+
}
|
|
563
|
+
if (action.type === "tool-ran") {
|
|
564
|
+
if (!input.sessionId || !input.requestHash) return "";
|
|
565
|
+
const answered = clearPending(deps.home, pendingId(input.sessionId, input.requestHash));
|
|
566
|
+
return answered ? `hook ${agent} ${input.event}: the approval request was answered, no needs_input` : "";
|
|
567
|
+
}
|
|
568
|
+
const now = deps.now();
|
|
569
|
+
if (action.type === "clear-signal") {
|
|
570
|
+
if (input.sessionId) clearSignaled(deps.home, signalKey("", input.sessionId));
|
|
571
|
+
const arming = findArming(deps.home, cwd, now);
|
|
572
|
+
if (arming) clearSignaled(deps.home, arming.key);
|
|
573
|
+
return "";
|
|
574
|
+
}
|
|
575
|
+
if (action.type === "await-answer") {
|
|
576
|
+
if (!findArming(deps.home, cwd, now)) return "";
|
|
577
|
+
if (input.sessionId && input.requestHash) {
|
|
578
|
+
const id = pendingId(input.sessionId, input.requestHash);
|
|
579
|
+
try {
|
|
580
|
+
recordPending(deps.home, id, { at: now, cwd, sessionId: input.sessionId, turnId: input.turnId });
|
|
581
|
+
deps.wait(id, now);
|
|
582
|
+
return `hook ${agent} ${input.event}: needs_input in ${PENDING_WAIT_MS / 1e3}s unless answered`;
|
|
583
|
+
} catch {
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return await sendAction(agent, NEEDS_INPUT, input, cwd, deps);
|
|
587
|
+
}
|
|
588
|
+
return await sendAction(agent, action, input, cwd, deps);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
return `hook ${agent}: error ${e instanceof Error ? e.message : String(e)}`;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function firePending(id, at, deps) {
|
|
594
|
+
try {
|
|
595
|
+
const pending = takePending(deps.home, id, at);
|
|
596
|
+
if (!pending) return "";
|
|
597
|
+
const input = { event: "PermissionRequest", sessionId: pending.sessionId, turnId: pending.turnId };
|
|
598
|
+
const note = await sendAction("codex", NEEDS_INPUT, input, pending.cwd, deps);
|
|
599
|
+
return note && `${note}, unanswered for ${PENDING_WAIT_MS / 1e3}s`;
|
|
600
|
+
} catch (e) {
|
|
601
|
+
return `hook codex: error ${e instanceof Error ? e.message : String(e)}`;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function sendAction(agent, action, input, cwd, deps) {
|
|
605
|
+
const now = deps.now();
|
|
606
|
+
const arming = findArming(deps.home, cwd, now);
|
|
607
|
+
if (!arming) return "";
|
|
608
|
+
const event = buildEvent({
|
|
609
|
+
kind: action.kind,
|
|
610
|
+
level: arming.level ?? "alarm",
|
|
611
|
+
label: labelFor(arming.dir ?? cwd),
|
|
612
|
+
agent: agentOf(agent),
|
|
613
|
+
now,
|
|
614
|
+
idParts: [agent, input.sessionId, input.turnId ?? input.promptId, action.kind]
|
|
615
|
+
});
|
|
616
|
+
if (action.consume && !claimEvent(deps.home, event.id, now)) {
|
|
617
|
+
return `hook ${agent} ${input.event}: skipped, already handled for this turn`;
|
|
618
|
+
}
|
|
619
|
+
if (action.fallback) {
|
|
620
|
+
const keys = [signalKey(arming.key, input.sessionId), arming.key];
|
|
621
|
+
if (keys.some((key) => isSignaled(deps.home, key))) {
|
|
622
|
+
for (const key of keys) clearSignaled(deps.home, key);
|
|
623
|
+
return `hook ${agent} ${input.event}: skipped, the agent already signalled this turn`;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (action.rateLimitMs && rateLimited(deps.home, arming.key, action.kind, now, action.rateLimitMs)) {
|
|
627
|
+
return `hook ${agent} ${input.event}: skipped, ${action.kind} sent less than ${action.rateLimitMs / 1e3}s ago`;
|
|
628
|
+
}
|
|
629
|
+
if (action.consume && !claim(deps.home, arming)) {
|
|
630
|
+
return `hook ${agent} ${input.event}: skipped, another alert already ended this arming`;
|
|
631
|
+
}
|
|
632
|
+
await deps.send(event);
|
|
633
|
+
return `hook ${agent} ${input.event}: ${action.kind} queued`;
|
|
634
|
+
}
|
|
635
|
+
async function sendSignal(kind, agent, cwd, deps, sessionId) {
|
|
636
|
+
const now = deps.now();
|
|
637
|
+
const arming = findArming(deps.home, cwd, now);
|
|
638
|
+
if (!arming) return { sent: false, message: "Not armed here, so nothing was sent." };
|
|
639
|
+
const key = signalKey(arming.key, sessionId);
|
|
640
|
+
const already = { sent: false, message: "That alert was already sent." };
|
|
641
|
+
if (signaledKind(deps.home, key) === kind) return already;
|
|
642
|
+
try {
|
|
643
|
+
markSignaled(deps.home, key, now, kind);
|
|
644
|
+
} catch {
|
|
645
|
+
}
|
|
646
|
+
if (kind !== "needs_input" && !claim(deps.home, arming)) return already;
|
|
647
|
+
const event = buildEvent({ kind, level: arming.level ?? "alarm", label: labelFor(arming.dir ?? cwd), agent, now });
|
|
648
|
+
await deps.send(event);
|
|
649
|
+
const what = kind === "needs_input" ? "needs you" : kind;
|
|
650
|
+
return { sent: true, message: `${arming.level === "notify" ? "Notification" : "Alarm"} sent: ${what} (${event.label}).` };
|
|
651
|
+
}
|
|
652
|
+
async function runSignal(kind, agent, deps, sessionId) {
|
|
653
|
+
return sendSignal(kind, agent, deps.cwd(), deps, sessionId);
|
|
654
|
+
}
|
|
655
|
+
function sessionFromEnv(agent, env) {
|
|
656
|
+
if (agent === "claude-code") return env.CLAUDE_CODE_SESSION_ID || void 0;
|
|
657
|
+
if (agent === "codex") return env.CODEX_THREAD_ID || env.CODEX_SESSION_ID || void 0;
|
|
658
|
+
return void 0;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/install.ts
|
|
662
|
+
import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmdirSync, rmSync as rmSync2 } from "node:fs";
|
|
663
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
664
|
+
|
|
665
|
+
// src/skill.ts
|
|
666
|
+
function skillMarkdown(cli, name, allowedTools) {
|
|
667
|
+
return `---
|
|
668
|
+
name: ${name}
|
|
669
|
+
description: Ring the user's phone with a loud alarm when this task is finished or when you need their input. Use when the user asks to be woken, alarmed, called or pinged when the work is done, says they're going to sleep or stepping away while you work, or invokes this skill directly.
|
|
670
|
+
${allowedTools ? `allowed-tools: ${allowedTools}
|
|
671
|
+
` : ""}---
|
|
672
|
+
|
|
673
|
+
# Code Alarm
|
|
674
|
+
|
|
675
|
+
The user wants their phone to ring when this task needs them. The alarm is loud, so send it only
|
|
676
|
+
when it's really time for them to come back.
|
|
677
|
+
|
|
678
|
+
1. **Before you start the work**, arm it from the project folder:
|
|
679
|
+
\`${cli} arm\`
|
|
680
|
+
If the user wants a quiet notification instead of an alarm (they said "quiet", "notify" or "no
|
|
681
|
+
alarm", or passed it as an argument), arm with \`${cli} arm --notify\` instead.
|
|
682
|
+
If it says Code Alarm isn't set up, tell the user to run \`code-alarm init\` in a terminal, and
|
|
683
|
+
continue with the task without the alarm.
|
|
684
|
+
2. Do the task as usual.
|
|
685
|
+
3. At the end, as your very last action, send exactly one of these:
|
|
686
|
+
- \`${cli} done\` when the user's whole request is complete.
|
|
687
|
+
- \`${cli} needs-you\` right before you stop to ask a question or wait for a decision you can't
|
|
688
|
+
make yourself.
|
|
689
|
+
- \`${cli} failed\` when you're giving up because something is broken.
|
|
690
|
+
|
|
691
|
+
These commands take no message, on purpose: the alarm only says done, needs you or failed, and the
|
|
692
|
+
project folder's name. Never add task details, code or file paths to them.
|
|
693
|
+
|
|
694
|
+
If you forget step 3, the alarm still rings when you stop, labelled "stopped". It's less helpful,
|
|
695
|
+
but the user isn't left waiting.
|
|
696
|
+
`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/install.ts
|
|
700
|
+
function codexPaths(alarmHome2, userHome) {
|
|
701
|
+
return {
|
|
702
|
+
hooksFile: join4(userHome, ".codex", "hooks.json"),
|
|
703
|
+
rulesFile: join4(userHome, ".codex", "rules", "code-alarm.rules"),
|
|
704
|
+
skillDir: join4(userHome, ".agents", "skills", "code-alarm"),
|
|
705
|
+
// A fixed path: Codex trusts a hook by its exact command, so updating code-alarm must not change it.
|
|
706
|
+
cli: join4(alarmHome2, "bin", "code-alarm.mjs")
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
var CODEX_EVENTS = ["Stop", "PermissionRequest", "PostToolUse", "UserPromptSubmit"];
|
|
710
|
+
var slashes = (path) => path.replace(/\\/g, "/");
|
|
711
|
+
function codexCommand(cli) {
|
|
712
|
+
return `node "${slashes(cli)}" hook codex`;
|
|
713
|
+
}
|
|
714
|
+
var UNSAFE_IN_QUOTES = /["$`]/;
|
|
715
|
+
var RULES_MARK = "Written by `code-alarm install codex`";
|
|
716
|
+
var SKILL_MARK = "<!-- Written by `code-alarm install codex`";
|
|
717
|
+
function codexRules(cli) {
|
|
718
|
+
const path = JSON.stringify(slashes(cli));
|
|
719
|
+
return `# ${RULES_MARK}; removed by \`code-alarm uninstall codex\`.
|
|
720
|
+
# Lets Codex run Code Alarm's own commands without asking: they only change its arming state
|
|
721
|
+
# and send an alarm.
|
|
722
|
+
prefix_rule(
|
|
723
|
+
pattern = ["node", ${path}, ["arm", "disarm", "nap", "status", "done", "needs-you", "failed"]],
|
|
724
|
+
decision = "allow",
|
|
725
|
+
justification = "Code Alarm's own commands: arming state and alarms only.",
|
|
726
|
+
match = [["node", ${path}, "done"], ["node", ${path}, "arm", "--notify"]],
|
|
727
|
+
not_match = [["node", ${path}, "install", "codex"]],
|
|
728
|
+
)
|
|
729
|
+
`;
|
|
730
|
+
}
|
|
731
|
+
var isOurs = (handler) => typeof handler.command === "string" && /code-alarm\.mjs"? hook codex/.test(handler.command);
|
|
732
|
+
function withoutCodeAlarm(file) {
|
|
733
|
+
if (!file.hooks) return { ...file };
|
|
734
|
+
const hooks = {};
|
|
735
|
+
for (const [event, groups] of Object.entries(file.hooks)) {
|
|
736
|
+
const kept = groups.map((group) => ({ ...group, hooks: (group.hooks ?? []).filter((handler) => !isOurs(handler)) })).filter((group) => group.hooks.length > 0);
|
|
737
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
738
|
+
}
|
|
739
|
+
return { ...file, hooks };
|
|
740
|
+
}
|
|
741
|
+
function withCodeAlarm(file, cli) {
|
|
742
|
+
const result = withoutCodeAlarm(file);
|
|
743
|
+
const hooks = { ...result.hooks ?? {} };
|
|
744
|
+
for (const event of CODEX_EVENTS) {
|
|
745
|
+
const group = { hooks: [{ type: "command", command: codexCommand(cli), timeout: 10, async: true }] };
|
|
746
|
+
hooks[event] = [...hooks[event] ?? [], group];
|
|
747
|
+
}
|
|
748
|
+
return { ...result, hooks };
|
|
749
|
+
}
|
|
750
|
+
function readHooksFile(path) {
|
|
751
|
+
if (!existsSync3(path)) return {};
|
|
752
|
+
try {
|
|
753
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
754
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
755
|
+
} catch {
|
|
756
|
+
}
|
|
757
|
+
throw new Error(`${path} isn't valid JSON, so code-alarm won't touch it. Fix or move it, then run this again.`);
|
|
758
|
+
}
|
|
759
|
+
var isOurFile = (path, mark) => readFileSync4(path, "utf8").includes(mark);
|
|
760
|
+
function setAside(path, mark, now, notes) {
|
|
761
|
+
if (!existsSync3(path) || isOurFile(path, mark)) return;
|
|
762
|
+
const backup = `${path}.code-alarm-backup-${now}`;
|
|
763
|
+
copyFileSync(path, backup);
|
|
764
|
+
notes.push(`Backed up ${path} to ${backup}`);
|
|
765
|
+
}
|
|
766
|
+
function removeOurs(path, mark, notes) {
|
|
767
|
+
if (!existsSync3(path)) return;
|
|
768
|
+
if (!isOurFile(path, mark)) {
|
|
769
|
+
notes.push(`Left ${path} alone: code-alarm didn't write it`);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
rmSync2(path, { force: true });
|
|
773
|
+
notes.push(`Removed ${path}`);
|
|
774
|
+
}
|
|
775
|
+
function installCodex(args) {
|
|
776
|
+
const paths = codexPaths(args.alarmHome, args.userHome);
|
|
777
|
+
const notes = [];
|
|
778
|
+
if (UNSAFE_IN_QUOTES.test(paths.cli)) {
|
|
779
|
+
throw new Error(`${paths.cli} contains " $ or \`, which can't be quoted safely in a hook command. Set CODE_ALARM_HOME to another folder and run this again.`);
|
|
780
|
+
}
|
|
781
|
+
const current = readHooksFile(paths.hooksFile);
|
|
782
|
+
mkdirSync4(dirname2(paths.cli), { recursive: true });
|
|
783
|
+
copyFileSync(args.bundle, paths.cli);
|
|
784
|
+
notes.push(`Copied the CLI to ${paths.cli}`);
|
|
785
|
+
if (existsSync3(paths.hooksFile)) {
|
|
786
|
+
const backup = `${paths.hooksFile}.code-alarm-backup-${args.now}`;
|
|
787
|
+
copyFileSync(paths.hooksFile, backup);
|
|
788
|
+
notes.push(`Backed up ${paths.hooksFile} to ${backup}`);
|
|
789
|
+
}
|
|
790
|
+
writeFileAtomic(paths.hooksFile, JSON.stringify(withCodeAlarm(current, paths.cli), null, 2) + "\n");
|
|
791
|
+
notes.push(`Added hooks for ${CODEX_EVENTS.join(", ")} to ${paths.hooksFile}`);
|
|
792
|
+
setAside(paths.rulesFile, RULES_MARK, args.now, notes);
|
|
793
|
+
writeFileAtomic(paths.rulesFile, codexRules(paths.cli));
|
|
794
|
+
notes.push(`Allowed Code Alarm's own commands in ${paths.rulesFile}`);
|
|
795
|
+
const skillFile = join4(paths.skillDir, "SKILL.md");
|
|
796
|
+
setAside(skillFile, SKILL_MARK, args.now, notes);
|
|
797
|
+
const skill = skillMarkdown(`node "${slashes(paths.cli)}"`, "code-alarm");
|
|
798
|
+
writeFileAtomic(skillFile, `${skill}
|
|
799
|
+
${SKILL_MARK}; removed by \`code-alarm uninstall codex\`. -->
|
|
800
|
+
`);
|
|
801
|
+
notes.push(`Installed the skill in ${paths.skillDir}`);
|
|
802
|
+
return notes;
|
|
803
|
+
}
|
|
804
|
+
function uninstallCodex(args) {
|
|
805
|
+
const paths = codexPaths(args.alarmHome, args.userHome);
|
|
806
|
+
const notes = [];
|
|
807
|
+
if (existsSync3(paths.hooksFile)) {
|
|
808
|
+
writeFileAtomic(paths.hooksFile, JSON.stringify(withoutCodeAlarm(readHooksFile(paths.hooksFile)), null, 2) + "\n");
|
|
809
|
+
notes.push(`Removed code-alarm's hooks from ${paths.hooksFile}`);
|
|
810
|
+
}
|
|
811
|
+
removeOurs(paths.rulesFile, RULES_MARK, notes);
|
|
812
|
+
removeOurs(join4(paths.skillDir, "SKILL.md"), SKILL_MARK, notes);
|
|
813
|
+
try {
|
|
814
|
+
rmdirSync(paths.skillDir);
|
|
815
|
+
} catch {
|
|
816
|
+
}
|
|
817
|
+
rmSync2(paths.cli, { force: true });
|
|
818
|
+
return notes;
|
|
819
|
+
}
|
|
820
|
+
function codexInstalled(alarmHome2, userHome) {
|
|
821
|
+
try {
|
|
822
|
+
const file = readHooksFile(codexPaths(alarmHome2, userHome).hooksFile);
|
|
823
|
+
return Object.values(file.hooks ?? {}).some((groups) => groups.some((group) => (group.hooks ?? []).some(isOurs)));
|
|
824
|
+
} catch {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// src/paths.ts
|
|
830
|
+
import { homedir } from "node:os";
|
|
831
|
+
import { join as join5 } from "node:path";
|
|
832
|
+
function alarmHome(env = process.env, platform = process.platform) {
|
|
833
|
+
if (env.CODE_ALARM_HOME) return env.CODE_ALARM_HOME;
|
|
834
|
+
if (platform === "win32" && env.APPDATA) return join5(env.APPDATA, "code-alarm");
|
|
835
|
+
return join5(env.XDG_CONFIG_HOME || join5(homedir(), ".config"), "code-alarm");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// src/main.ts
|
|
839
|
+
var VERSION = true ? "0.1.0" : "dev";
|
|
840
|
+
var DEFAULT_ARM_MS = 12 * HOUR;
|
|
841
|
+
var HELP = `code-alarm ${VERSION}: a real alarm on your phone when Claude Code or Codex finishes or needs you.
|
|
842
|
+
|
|
843
|
+
Setup
|
|
844
|
+
code-alarm init [--server URL] [--topic NAME] [--token TOKEN] [--force]
|
|
845
|
+
Create a private ntfy topic and show how to subscribe on the phone
|
|
846
|
+
code-alarm test [--notify] Ring the phone now (or send a plain notification)
|
|
847
|
+
code-alarm install codex Add Code Alarm's hooks and skill to Codex (uninstall codex removes them)
|
|
848
|
+
|
|
849
|
+
Arming (only armed work rings)
|
|
850
|
+
code-alarm arm [--for 12h] [--notify]
|
|
851
|
+
Ring when the agent in this folder next finishes, stops or needs you
|
|
852
|
+
code-alarm nap <2h> [--notify] Ring for every session until the time is up
|
|
853
|
+
--notify: one ordinary notification instead of the alarm
|
|
854
|
+
code-alarm disarm [--all] Cancel arming for this folder (or everything)
|
|
855
|
+
code-alarm status What's set up, what's armed, the last alerts
|
|
856
|
+
|
|
857
|
+
Run by the agent (its skill says when)
|
|
858
|
+
code-alarm done | needs-you | failed
|
|
859
|
+
|
|
860
|
+
Run by agent hooks
|
|
861
|
+
code-alarm hook claude|codex Reads the hook's JSON on stdin; prints nothing, always exits 0
|
|
862
|
+
|
|
863
|
+
Claude Code users install the plugin instead of running anything here by hand:
|
|
864
|
+
claude plugin marketplace add Draganoider/code-alarm
|
|
865
|
+
claude plugin install code-alarm@code-alarm
|
|
866
|
+
|
|
867
|
+
https://github.com/Draganoider/code-alarm`;
|
|
868
|
+
async function readStdin(timeoutMs = 3e3) {
|
|
869
|
+
if (process.stdin.isTTY) return "";
|
|
870
|
+
return new Promise((done) => {
|
|
871
|
+
let data = "";
|
|
872
|
+
const timer = setTimeout(() => {
|
|
873
|
+
process.stdin.destroy();
|
|
874
|
+
done(data);
|
|
875
|
+
}, timeoutMs);
|
|
876
|
+
process.stdin.setEncoding("utf8");
|
|
877
|
+
process.stdin.on("data", (chunk) => data += chunk);
|
|
878
|
+
process.stdin.on("end", () => {
|
|
879
|
+
clearTimeout(timer);
|
|
880
|
+
done(data);
|
|
881
|
+
});
|
|
882
|
+
process.stdin.on("error", () => {
|
|
883
|
+
clearTimeout(timer);
|
|
884
|
+
done(data);
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
function detectAgent(env) {
|
|
889
|
+
if (env.CLAUDECODE === "1" || env.CLAUDE_CODE_ENTRYPOINT) return "claude-code";
|
|
890
|
+
if (Object.keys(env).some((key) => key.startsWith("CODEX_"))) return "codex";
|
|
891
|
+
return "cli";
|
|
892
|
+
}
|
|
893
|
+
var clock = (ms) => new Date(ms).toTimeString().slice(0, 5);
|
|
894
|
+
function setupHint() {
|
|
895
|
+
return "Code Alarm isn't set up on this computer yet. Run `code-alarm init` in a terminal.";
|
|
896
|
+
}
|
|
897
|
+
async function main(argv) {
|
|
898
|
+
const [command, ...rest] = argv;
|
|
899
|
+
const home = alarmHome();
|
|
900
|
+
const deps = {
|
|
901
|
+
home,
|
|
902
|
+
now: Date.now,
|
|
903
|
+
send: (event) => sendDetached(event),
|
|
904
|
+
wait: (id, at) => waitDetached(id, at),
|
|
905
|
+
cwd: () => process.cwd()
|
|
906
|
+
};
|
|
907
|
+
switch (command) {
|
|
908
|
+
case "hook": {
|
|
909
|
+
const agent = rest[0];
|
|
910
|
+
if (agent !== "claude" && agent !== "codex") return 0;
|
|
911
|
+
const note = await runHook(agent, await readStdin(), deps);
|
|
912
|
+
if (note) logLine(home, note);
|
|
913
|
+
return 0;
|
|
914
|
+
}
|
|
915
|
+
case "__send": {
|
|
916
|
+
const event = decodeEvent(rest[0] ?? "");
|
|
917
|
+
if (event) await sendNow(home, event);
|
|
918
|
+
return 0;
|
|
919
|
+
}
|
|
920
|
+
case "__wait": {
|
|
921
|
+
const [id = "", at = ""] = rest;
|
|
922
|
+
if (!/^[\w-]+$/.test(id) || !/^\d+$/.test(at)) return 0;
|
|
923
|
+
await new Promise((done) => setTimeout(done, PENDING_WAIT_MS));
|
|
924
|
+
const note = await firePending(id, Number(at), deps);
|
|
925
|
+
if (note) logLine(home, note);
|
|
926
|
+
return 0;
|
|
927
|
+
}
|
|
928
|
+
case "__skill":
|
|
929
|
+
process.stdout.write(skillMarkdown(rest[0] ?? "code-alarm", rest[1] ?? "alarm", rest[2]));
|
|
930
|
+
return 0;
|
|
931
|
+
case "init": {
|
|
932
|
+
const { values } = parseArgs({
|
|
933
|
+
args: rest,
|
|
934
|
+
options: { server: { type: "string" }, topic: { type: "string" }, token: { type: "string" }, force: { type: "boolean" } }
|
|
935
|
+
});
|
|
936
|
+
const existing = values.force ? null : loadConfig(home);
|
|
937
|
+
if (existing) {
|
|
938
|
+
console.log(`Already set up: ntfy topic ${existing.ntfy.topic} on ${existing.ntfy.server}.`);
|
|
939
|
+
console.log("Run `code-alarm init --force` to start over with a new topic.");
|
|
940
|
+
return 0;
|
|
941
|
+
}
|
|
942
|
+
const topic = values.topic ?? newTopic();
|
|
943
|
+
if (!isValidTopic(topic)) throw new ConfigError("A topic can only use letters, digits, - and _ (at most 64).");
|
|
944
|
+
const server = (values.server ?? DEFAULT_NTFY_SERVER).replace(/\/+$/, "");
|
|
945
|
+
saveConfig(home, { version: 1, transport: "ntfy", ntfy: { server, topic, ...values.token ? { token: values.token } : {} } });
|
|
946
|
+
console.log(`Code Alarm is set up.
|
|
947
|
+
|
|
948
|
+
Server: ${server}
|
|
949
|
+
Topic: ${topic}
|
|
950
|
+
Treat the topic like a password: anyone who knows it can ring your phone.
|
|
951
|
+
|
|
952
|
+
On your phone:
|
|
953
|
+
1. Install the ntfy app (Google Play, F-Droid or the App Store).
|
|
954
|
+
2. Subscribe to the topic above${server === DEFAULT_NTFY_SERVER ? "" : `, on the server ${server}`}.
|
|
955
|
+
3. In that subscription's settings, turn on the option that keeps alerting for highest-priority
|
|
956
|
+
messages, and the one that lets it through Do Not Disturb. (Names vary a little between app
|
|
957
|
+
versions.)
|
|
958
|
+
4. Back here, run: code-alarm test
|
|
959
|
+
|
|
960
|
+
Then connect your agent:
|
|
961
|
+
Claude Code: claude plugin marketplace add Draganoider/code-alarm
|
|
962
|
+
claude plugin install code-alarm@code-alarm
|
|
963
|
+
Codex: code-alarm install codex`);
|
|
964
|
+
return 0;
|
|
965
|
+
}
|
|
966
|
+
case "test": {
|
|
967
|
+
const { values } = parseArgs({ args: rest, options: { notify: { type: "boolean" } } });
|
|
968
|
+
if (!loadConfig(home)) {
|
|
969
|
+
console.log(setupHint());
|
|
970
|
+
return 1;
|
|
971
|
+
}
|
|
972
|
+
const event = buildEvent({ kind: "test", level: values.notify ? "notify" : "alarm", label: "test", agent: "cli", now: Date.now() });
|
|
973
|
+
const result = await sendNow(home, event);
|
|
974
|
+
const expected = values.notify ? "You should get one notification now, with sound and vibration." : "Your phone should be ringing now, until you dismiss it.";
|
|
975
|
+
console.log(result.ok ? `Sent. ${expected}` : `Couldn't send the test: ${result.detail}.`);
|
|
976
|
+
return result.ok ? 0 : 1;
|
|
977
|
+
}
|
|
978
|
+
case "arm": {
|
|
979
|
+
const { values } = parseArgs({ args: rest, options: { for: { type: "string" }, notify: { type: "boolean" } } });
|
|
980
|
+
if (!loadConfig(home)) {
|
|
981
|
+
console.log(setupHint());
|
|
982
|
+
return 1;
|
|
983
|
+
}
|
|
984
|
+
const ms = values.for ? parseDuration(values.for) : DEFAULT_ARM_MS;
|
|
985
|
+
if (!ms || ms > MAX_ARM_MS) throw new ConfigError(`--for takes a duration like 2h, 90m or 1h30m, at most 24h.`);
|
|
986
|
+
const now = Date.now();
|
|
987
|
+
const level = values.notify ? "notify" : "alarm";
|
|
988
|
+
const arming = armProject(home, process.cwd(), now + ms, now, level);
|
|
989
|
+
console.log(
|
|
990
|
+
`Armed "${labelFor(arming.dir ?? process.cwd())}" until ${clock(arming.until)} (${formatDuration(ms)}). ` + (level === "notify" ? "The phone gets one notification the next time the agent here finishes, stops or needs you." : "The phone rings the next time the agent here finishes, stops or needs you.")
|
|
991
|
+
);
|
|
992
|
+
return 0;
|
|
993
|
+
}
|
|
994
|
+
case "nap": {
|
|
995
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { notify: { type: "boolean" } } });
|
|
996
|
+
const ms = positionals[0] ? parseDuration(positionals[0]) : null;
|
|
997
|
+
if (!ms || ms > MAX_ARM_MS) throw new ConfigError("Usage: code-alarm nap <duration>, like 2h or 45m (at most 24h).");
|
|
998
|
+
if (!loadConfig(home)) {
|
|
999
|
+
console.log(setupHint());
|
|
1000
|
+
return 1;
|
|
1001
|
+
}
|
|
1002
|
+
const now = Date.now();
|
|
1003
|
+
armNap(home, now + ms, now, values.notify ? "notify" : "alarm");
|
|
1004
|
+
console.log(
|
|
1005
|
+
`Nap mode until ${clock(now + ms)}: every session that finishes, stops or needs you ` + (values.notify ? "sends a notification." : "rings the phone.")
|
|
1006
|
+
);
|
|
1007
|
+
return 0;
|
|
1008
|
+
}
|
|
1009
|
+
case "disarm": {
|
|
1010
|
+
const { values } = parseArgs({ args: rest, options: { all: { type: "boolean" } } });
|
|
1011
|
+
if (values.all) {
|
|
1012
|
+
console.log(`Disarmed ${disarmAll(home)} arming(s).`);
|
|
1013
|
+
} else {
|
|
1014
|
+
console.log(disarmProject(home, process.cwd()) ? "Disarmed this folder." : "This folder wasn't armed.");
|
|
1015
|
+
}
|
|
1016
|
+
return 0;
|
|
1017
|
+
}
|
|
1018
|
+
case "status": {
|
|
1019
|
+
const config = loadConfig(home);
|
|
1020
|
+
console.log(
|
|
1021
|
+
config ? (
|
|
1022
|
+
// Never any part of the topic: people paste this output into bug reports.
|
|
1023
|
+
`Transport: ntfy, ${config.ntfy.server}, topic set${config.ntfy.token ? ", with a token" : ""}`
|
|
1024
|
+
) : "Not set up: run `code-alarm init`."
|
|
1025
|
+
);
|
|
1026
|
+
const now = Date.now();
|
|
1027
|
+
const armings = listArmings(home, now);
|
|
1028
|
+
console.log(armings.length ? "Armed:" : "Nothing is armed.");
|
|
1029
|
+
for (const arming of armings) {
|
|
1030
|
+
const what = arming.kind === "nap" ? "nap mode, every session" : `${arming.dir}`;
|
|
1031
|
+
const level = arming.level === "notify" ? ", notification only" : "";
|
|
1032
|
+
console.log(` - ${what} until ${clock(arming.until)} (${formatDuration(arming.until - now)} left${level})`);
|
|
1033
|
+
}
|
|
1034
|
+
console.log(`Codex hooks: ${codexInstalled(home, homedir2()) ? "installed" : "not installed"}`);
|
|
1035
|
+
const log = recentLog(home, 5);
|
|
1036
|
+
if (log.length) console.log(`Last activity:
|
|
1037
|
+
${log.map((line) => ` ${line}`).join("\n")}`);
|
|
1038
|
+
return 0;
|
|
1039
|
+
}
|
|
1040
|
+
case "done":
|
|
1041
|
+
case "needs-you":
|
|
1042
|
+
case "failed": {
|
|
1043
|
+
const kind = command === "needs-you" ? "needs_input" : command;
|
|
1044
|
+
const agent = detectAgent(process.env);
|
|
1045
|
+
const result = await runSignal(kind, agent, deps, sessionFromEnv(agent, process.env));
|
|
1046
|
+
console.log(result.message);
|
|
1047
|
+
return 0;
|
|
1048
|
+
}
|
|
1049
|
+
case "install":
|
|
1050
|
+
case "uninstall": {
|
|
1051
|
+
if (rest[0] !== "codex") throw new ConfigError(`Usage: code-alarm ${command} codex`);
|
|
1052
|
+
const script = process.argv[1];
|
|
1053
|
+
if (command === "install") {
|
|
1054
|
+
if (!script) throw new ConfigError("Can't tell where this code-alarm script is.");
|
|
1055
|
+
for (const note of installCodex({ alarmHome: home, userHome: homedir2(), bundle: resolve3(script), now: Date.now() })) console.log(note);
|
|
1056
|
+
console.log(
|
|
1057
|
+
`
|
|
1058
|
+
Next: quit Codex completely and start it again. It reads rules only at startup, and closing
|
|
1059
|
+
its window may leave it running in the background. Then approve (trust) the new hooks when it
|
|
1060
|
+
asks; Codex skips hooks until you do.
|
|
1061
|
+
Then, before a long task, ask Codex to use the code-alarm skill (or say "wake me when it's done").`
|
|
1062
|
+
);
|
|
1063
|
+
} else {
|
|
1064
|
+
for (const note of uninstallCodex({ alarmHome: home, userHome: homedir2() })) console.log(note);
|
|
1065
|
+
}
|
|
1066
|
+
return 0;
|
|
1067
|
+
}
|
|
1068
|
+
case "version":
|
|
1069
|
+
case "--version":
|
|
1070
|
+
case "-v":
|
|
1071
|
+
console.log(VERSION);
|
|
1072
|
+
return 0;
|
|
1073
|
+
case void 0:
|
|
1074
|
+
case "help":
|
|
1075
|
+
case "--help":
|
|
1076
|
+
case "-h":
|
|
1077
|
+
console.log(HELP);
|
|
1078
|
+
return 0;
|
|
1079
|
+
default:
|
|
1080
|
+
console.error(`Unknown command: ${command}
|
|
1081
|
+
|
|
1082
|
+
${HELP}`);
|
|
1083
|
+
return 1;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
var quiet = ["hook", "__send", "__wait"].includes(process.argv[2] ?? "");
|
|
1087
|
+
main(process.argv.slice(2)).then(
|
|
1088
|
+
(code) => {
|
|
1089
|
+
process.exitCode = quiet ? 0 : code;
|
|
1090
|
+
},
|
|
1091
|
+
(e) => {
|
|
1092
|
+
if (quiet) {
|
|
1093
|
+
process.exitCode = 0;
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
console.error(`code-alarm: ${e instanceof Error ? e.message : String(e)}`);
|
|
1097
|
+
process.exitCode = 1;
|
|
1098
|
+
}
|
|
1099
|
+
);
|
|
1100
|
+
export {
|
|
1101
|
+
main
|
|
1102
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-alarm",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Wake up when Claude Code or Codex finishes or needs you: a real alarm on your phone, from agent hooks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Yevhen Mishchenko",
|
|
7
|
+
"homepage": "https://github.com/Draganoider/code-alarm#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Draganoider/code-alarm.git",
|
|
11
|
+
"directory": "cli"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/Draganoider/code-alarm/issues",
|
|
14
|
+
"keywords": ["claude-code", "codex", "alarm", "notifications", "ntfy", "hooks", "ai-agents"],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"code-alarm": "dist/code-alarm.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": ["dist/code-alarm.mjs"],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
25
|
+
"build": "node build.mjs",
|
|
26
|
+
"test": "tsc -p tsconfig.test.json && node --test \"build/test/**/*.test.js\"",
|
|
27
|
+
"check": "npm run typecheck && npm test && npm run build",
|
|
28
|
+
"prepack": "node -e \"for (const f of ['LICENSE', 'README.md']) require('node:fs').copyFileSync('../' + f, f)\""
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^26.6.5",
|
|
32
|
+
"esbuild": "^0.28.2",
|
|
33
|
+
"typescript": "^7.0.2"
|
|
34
|
+
}
|
|
35
|
+
}
|