Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f

## [Unreleased]

## [0.2.1] — 2026-10-18

### Changed

- When the phone can't be reached because Code Alarm on it was reinstalled or its data cleared
(the relay's `phone_unreachable`), `code-alarm test` and the log now say so and how to fix it:
pair again with `code-alarm pair`, and remove the old computer in the app. `code-alarm status`
repeats it while the last alert since pairing failed that way, since a hook can only log it.

## [0.2.0] — 2026-10-16

### Added
Expand Down Expand Up @@ -74,5 +83,7 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f
quote safely; `uninstall codex` removes only its own files. The npm package includes the
license and README. `status` never shows the topic.

[Unreleased]: https://github.com/Draganoider/code-alarm/compare/v0.1.0...test
[Unreleased]: https://github.com/Draganoider/code-alarm/compare/v0.2.1...test
[0.2.1]: https://github.com/Draganoider/code-alarm/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/Draganoider/code-alarm/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/Draganoider/code-alarm/releases/tag/v0.1.0
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ reason. If it forgets, the alarm still rings when it stops, labelled *stopped*.
`code-alarm status` shows what's set up, what's armed and the last alerts. `code-alarm disarm`
cancels arming.

If you reinstall Code Alarm on your phone or clear its data, the phone registers afresh and this
computer's pairing stops reaching it. `code-alarm status` and `code-alarm test` say so: pair again
with `code-alarm pair`, and remove the old computer from the app's list.

Codex's hooks run their own copy of code-alarm. After you update code-alarm, `status` says if
that copy is out of date, and `init`, `pair`, `unpair` and `use` update it for you.

Expand Down
21 changes: 19 additions & 2 deletions cli/dist/code-alarm.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "code-alarm",
"version": "0.2.0",
"version": "0.2.1",
"description": "Wake up when Claude Code or Codex finishes or needs you: a real alarm on your phone, from agent hooks.",
"license": "MIT",
"author": "Yevhen Mishchenko",
Expand Down
13 changes: 13 additions & 0 deletions cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ export function appRequest(event: AlarmEvent, app: AppConfig): RelayRequest {
};
}

/**
* The relay's `phone_unreachable`: the push service no longer knows the phone this computer is
* paired with. Almost always the app was reinstalled or its data cleared, which registers the
* phone afresh and leaves every old pairing pointing at the old registration.
*/
export function phoneUnreachableAdvice(name: string): string {
return (
"The phone can't be reached: Code Alarm on it was probably reinstalled or its data cleared. " +
`Pair again with \`code-alarm pair\`, and in the app remove the old "${name}" if it's still listed`
);
}

/**
* Sends one event. A network error or a failed push is retried once with the same ID: the relay
* dedupes by ID, so a retry can never ring twice.
Expand All @@ -208,6 +220,7 @@ export async function sendApp(
return { ok: true, detail: answer.body.limited ? "app: sent as a notification (rate limit)" : "app: sent" };
}
if (answer.status === 401) return { ok: false, detail: "this computer isn't paired any more: run `code-alarm pair`" };
if (answer.body.error === "phone_unreachable") return { ok: false, detail: `app: phone_unreachable. ${phoneUnreachableAdvice(app.name)}` };
result = { ok: false, detail: `app: ${typeof answer.body.error === "string" ? answer.body.error : `HTTP ${answer.status}`}` };
if (answer.status !== 502) return result;
}
Expand Down
15 changes: 15 additions & 0 deletions cli/src/deliver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ export function recentLog(home: string, count: number): string[] {
}
}

export interface LoggedSend {
at: number;
ok: boolean;
detail: string;
}

/** The newest send in the log, sent or failed, for `status` to explain a failure the hook could only log. */
export function lastSend(home: string): LoggedSend | null {
for (const line of recentLog(home, 200).reverse()) {
const match = /^(\S+) \S+\/\S+ \S+ ".*" (sent|FAILED) \((.*)\)$/.exec(line);
if (match) return { at: Date.parse(match[1]!), ok: match[2] === "sent", detail: match[3]! };
}
return null;
}

/** Sends and waits for the answer: for `code-alarm test`, and inside the detached sender. */
export async function sendNow(home: string, event: AlarmEvent, fetchImpl?: typeof fetch): Promise<SendResult> {
const config = loadConfig(home);
Expand Down
12 changes: 10 additions & 2 deletions cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import { type PairingAsk, askToPair, defaultName, describeDelivery, fetchDelivery, pairWithPhone, pollPairing, relayUrl, unpairFromPhone } from "./app.js";
import { type PairingAsk, askToPair, defaultName, describeDelivery, fetchDelivery, pairWithPhone, phoneUnreachableAdvice, pollPairing, relayUrl, unpairFromPhone } from "./app.js";
import { type AppConfig, type Config, DEFAULT_NTFY_SERVER, ConfigError, isValidTopic, loadConfig, newTopic, removeConfig, saveConfig } from "./config.js";
import { decodeEvent, lastEvent, logLine, recentLog, sendDetached, sendNow, waitDetached } from "./deliver.js";
import { decodeEvent, lastEvent, lastSend, logLine, recentLog, sendDetached, sendNow, waitDetached } from "./deliver.js";
import { HOUR, MAX_ARM_MS, formatDuration, parseDuration } from "./duration.js";
import { type Agent, buildEvent, labelFor } from "./events.js";
import { type Deps, type HookAgent, PENDING_WAIT_MS, type SignalKind, firePending, runHook, runSignal, sessionFromEnv } from "./hook.js";
Expand Down Expand Up @@ -295,6 +295,14 @@ Then connect your agent:
console.log(`Codex hooks: ${codexInstalled(home, homedir()) ? `installed${copy ? `, ${copy}` : ""}` : "not installed"}`);
const log = recentLog(home, 5);
if (log.length) console.log(`Last activity:\n${log.map((line) => ` ${line}`).join("\n")}`);
if (config?.transport === "app" && config.app) {
// A hook can only log a failed send, so this is where a lost alert gets explained. Only a
// send since the current pairing counts: pairing again is the fix.
const send = lastSend(home);
if (send && !send.ok && send.at >= config.app.pairedAt && send.detail.includes("phone_unreachable")) {
console.log(`The last alert didn't reach the phone. ${phoneUnreachableAdvice(config.app.name)}.`);
}
}
if (values.last) {
const last = lastEvent(home);
if (config?.transport !== "app" || !config.app) console.log("Last alert: --last needs the Code Alarm app; ntfy doesn't report back.");
Expand Down
36 changes: 33 additions & 3 deletions cli/test/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test";
import { appRequest, defaultName, describeDelivery, fetchDelivery, pairWithPhone, relayUrl, sendApp, unpairFromPhone } from "../src/app.js";
import { type AppConfig, ConfigError, configPath, loadConfig, saveConfig } from "../src/config.js";
import { lastEvent, sendNow } from "../src/deliver.js";
import { lastEvent, lastSend, logLine, sendNow } from "../src/deliver.js";
import { buildEvent } from "../src/events.js";
import { tempDir } from "./helpers.js";

Expand Down Expand Up @@ -120,7 +120,7 @@ test("sendApp doesn't retry what a retry can't fix", async () => {
[json(410, { error: "expired" }), /app: expired/],
[json(400, { error: "invalid_event" }), /app: invalid_event/],
[json(429, { error: "rate_limited" }), /app: rate_limited/],
[json(503, { error: "phone_unreachable" }), /app: phone_unreachable/],
[json(503, { error: "phone_unreachable" }), /app: phone_unreachable\. .*reinstalled or its data cleared.*`code-alarm pair`.*"Windows PC"/],
] as const) {
const fake = fakeFetch(answer);
const result = await sendApp(event(), APP, fake.impl, { retryDelayMs: 0 });
Expand Down Expand Up @@ -168,6 +168,36 @@ test("sendNow uses the app when paired, never ntfy as well, and remembers the ev
assert.ok(!log.includes(APP.token));
});

test("a phone that was reinstalled: the log says what to do, and lastSend finds it", async () => {
const home = tempDir();
saveConfig(home, { version: 1, transport: "app", app: APP });
const fake = fakeFetch(json(503, { status: "failed", error: "phone_unreachable", message: "FCM no longer knows the phone." }));
const result = await sendNow(home, event(), fake.impl);
assert.equal(result.ok, false);
logLine(home, "hook claude Stop: done queued");
const send = lastSend(home)!;
assert.equal(send.ok, false);
assert.match(send.detail, /^app: phone_unreachable\. The phone can't be reached: .*reinstalled.*`code-alarm pair`.*"Windows PC"/);
assert.ok(Math.abs(send.at - Date.now()) < 60_000);
assert.ok(!readFileSync(join(home, "log.txt"), "utf8").includes(APP.token));
});

test("lastSend reads the newest send, including 0.2.0's lines, and skips hook notes", () => {
const home = tempDir();
assert.equal(lastSend(home), null);
writeFileSync(
join(home, "log.txt"),
[
'2026-10-18T09:00:00.000Z done/alarm claude-code "my (odd) project" sent (app: sent)',
'2026-10-18T09:05:00.000Z done/alarm codex "code-alarm" FAILED (app: phone_unreachable)',
"2026-10-18T09:05:01.000Z hook codex Stop: skipped, already handled for this turn",
].join("\n") + "\n",
);
assert.deepEqual(lastSend(home), { at: Date.parse("2026-10-18T09:05:00.000Z"), ok: false, detail: "app: phone_unreachable" });
logLine(home, 'test/alarm cli "test" sent (app: sent)');
assert.equal(lastSend(home)?.ok, true);
});

test("config: an app pairing round-trips next to ntfy, and a half-written one is refused", () => {
const home = tempDir();
saveConfig(home, { version: 1, transport: "app", app: APP });
Expand Down
29 changes: 29 additions & 0 deletions cli/test/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ interface Seen {
/** A stand-in for the relay: just enough of its API (docs/protocol.md) to drive the commands. */
const seen: Seen[] = [];
const qr = { tokenHash: "", polls: 0 };
/** Answers events the way the relay does once the phone's app was reinstalled. */
const relayState = { phoneGone: false };
let relay = "";
const server = createServer(async (req: IncomingMessage, res) => {
let raw = "";
Expand Down Expand Up @@ -50,6 +52,9 @@ const server = createServer(async (req: IncomingMessage, res) => {
if (body.code !== "K7Q-4MZ") return reply(404, { error: "invalid_code", message: "That code is wrong or has expired." });
return reply(201, { sender_id: "s_1", token: TOKEN, device_id: "d_1", name: body.name });
}
if (req.method === "POST" && req.url === "/v1/events" && authed && relayState.phoneGone) {
return reply(503, { status: "failed", error: "phone_unreachable", message: "FCM no longer knows the phone. Code Alarm was probably reinstalled or its data cleared: pair this computer again (code-alarm pair)." });
}
if (req.method === "POST" && req.url === "/v1/events" && authed) return reply(200, { status: "sent", id: body.id, level: body.level });
if (req.method === "GET" && req.url?.startsWith("/v1/events/") && authed) {
return reply(200, { status: "stopped", sent_at: 1000, ringing_at: 1500, ended_at: 4500 });
Expand Down Expand Up @@ -118,6 +123,30 @@ test("pair, test, status --last, use and unpair against a relay", async () => {
assert.equal(after.app, undefined);
});

test("a reinstalled phone: test and status say to pair again, until it's paired again", async () => {
const home = tempDir();
assert.equal((await run(["pair", "K7Q-4MZ"], home)).status, 0);
relayState.phoneGone = true;
try {
const sent = await run(["test"], home);
assert.equal(sent.status, 1);
assert.match(sent.stdout, /Couldn't send the test: app: phone_unreachable\. The phone can't be reached: Code Alarm on it was probably reinstalled or its data cleared\./);
assert.match(sent.stdout, new RegExp(`Pair again with \`code-alarm pair\`, and in the app remove the old "${defaultName()}" if it's still listed[.]`));

const status = await run(["status"], home);
assert.equal(status.status, 0);
assert.match(status.stdout, /The last alert didn't reach the phone\. The phone can't be reached: .*reinstalled.*`code-alarm pair`/);
assert.ok(!status.stdout.includes(TOKEN));
} finally {
relayState.phoneGone = false;
}

assert.equal((await run(["pair", "K7Q-4MZ"], home)).status, 0);
assert.doesNotMatch((await run(["status"], home)).stdout, /didn't reach the phone/, "pairing again is the fix, so the hint goes");
assert.equal((await run(["test"], home)).status, 0);
assert.doesNotMatch((await run(["status"], home)).stdout, /didn't reach the phone/);
});

test("pair refuses a wrong code and a relay that isn't HTTPS, and takes the relay from CODE_ALARM_RELAY", async () => {
const home = tempDir();
const wrong = await run(["pair", "AAA-AAA"], home);
Expand Down
3 changes: 2 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ isn't plain characters, and an event that lives longer than an hour or has expir
| 200 `{"status": "duplicate"}` | Done: the phone already has that `id`, so it won't ring again |
| 502 `push_failed` | Tries once more, with the same event |
| 401 | This computer was removed on the phone: pair again |
| 400, 410, 429, 503 | Logs it. 429 means over 60 events in an hour; 503 means the phone needs to open the app once |
| 503 `phone_unreachable` | Logs that the phone's app was probably reinstalled or its data cleared, and to pair again; `test` prints it and `status` repeats it until this computer pairs again or a later send gets through |
| 400, 410, 429 | Logs it. 429 means over 60 events in an hour |

*Status.* `code-alarm status --last` asks `GET <relay>/v1/events/<id>` what the phone did with the
last alert: whether it rang, how long after it was sent, and when it was stopped. The relay keeps
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "code-alarm",
"version": "0.2.0",
"version": "0.2.1",
"description": "A real alarm on your phone when Claude Code finishes or needs you. Arm a task with /code-alarm:alarm; only armed work rings.",
"author": {
"name": "Yevhen Mishchenko",
Expand Down
Loading
Loading