diff --git a/CHANGELOG.md b/CHANGELOG.md index 535f234..e384994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 945b5d5..68dd3e1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cli/dist/code-alarm.mjs b/cli/dist/code-alarm.mjs index a766cb4..2b39d2a 100755 --- a/cli/dist/code-alarm.mjs +++ b/cli/dist/code-alarm.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// code-alarm 0.2.0, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. +// code-alarm 0.2.1, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. // src/main.ts import { homedir as homedir2 } from "node:os"; @@ -194,6 +194,9 @@ function appRequest(event, app) { } }; } +function phoneUnreachableAdvice(name) { + 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`; +} async function sendApp(event, app, fetchImpl = fetch, options = {}) { const { timeoutMs = TIMEOUT_MS, retryDelayMs = 3e3 } = options; let result = { ok: false, detail: "not sent" }; @@ -211,6 +214,7 @@ async function sendApp(event, app, fetchImpl = fetch, options = {}) { 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; } @@ -371,6 +375,13 @@ function recentLog(home, count) { return []; } } +function lastSend(home) { + 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; +} async function sendNow(home, event, fetchImpl) { const config = loadConfig(home); if (!config) return { ok: false, detail: "not set up: run `code-alarm init` or `code-alarm pair`" }; @@ -1893,7 +1904,7 @@ function qrForTerminal(text) { } // src/main.ts -var VERSION = true ? "0.2.0" : "dev"; +var VERSION = true ? "0.2.1" : "dev"; var DEFAULT_ARM_MS = 12 * HOUR; var HELP = `code-alarm ${VERSION}: a real alarm on your phone when Claude Code or Codex finishes or needs you. @@ -2138,6 +2149,12 @@ Then connect your agent: const log = recentLog(home, 5); if (log.length) console.log(`Last activity: ${log.map((line) => ` ${line}`).join("\n")}`); + if (config?.transport === "app" && config.app) { + 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."); diff --git a/cli/package-lock.json b/cli/package-lock.json index 6f0c5a4..2914bfe 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-alarm", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-alarm", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "bin": { "code-alarm": "dist/code-alarm.mjs" diff --git a/cli/package.json b/cli/package.json index 84abc35..1451204 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/src/app.ts b/cli/src/app.ts index 370fd50..58b0d9a 100644 --- a/cli/src/app.ts +++ b/cli/src/app.ts @@ -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. @@ -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; } diff --git a/cli/src/deliver.ts b/cli/src/deliver.ts index 7fdee2e..fb5aa88 100644 --- a/cli/src/deliver.ts +++ b/cli/src/deliver.ts @@ -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 { const config = loadConfig(home); diff --git a/cli/src/main.ts b/cli/src/main.ts index 9ed36b7..4899c3e 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -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"; @@ -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."); diff --git a/cli/test/app.test.ts b/cli/test/app.test.ts index 51cd0e6..fbb0fdb 100644 --- a/cli/test/app.test.ts +++ b/cli/test/app.test.ts @@ -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"; @@ -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 }); @@ -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 }); diff --git a/cli/test/pair.test.ts b/cli/test/pair.test.ts index a115a53..3835aac 100644 --- a/cli/test/pair.test.ts +++ b/cli/test/pair.test.ts @@ -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 = ""; @@ -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 }); @@ -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); diff --git a/docs/protocol.md b/docs/protocol.md index 5cfa26c..98d189c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 /v1/events/` 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 diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 5dec252..574f05a 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/claude-code/bin/code-alarm.mjs b/plugins/claude-code/bin/code-alarm.mjs index a766cb4..2b39d2a 100755 --- a/plugins/claude-code/bin/code-alarm.mjs +++ b/plugins/claude-code/bin/code-alarm.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// code-alarm 0.2.0, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. +// code-alarm 0.2.1, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. // src/main.ts import { homedir as homedir2 } from "node:os"; @@ -194,6 +194,9 @@ function appRequest(event, app) { } }; } +function phoneUnreachableAdvice(name) { + 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`; +} async function sendApp(event, app, fetchImpl = fetch, options = {}) { const { timeoutMs = TIMEOUT_MS, retryDelayMs = 3e3 } = options; let result = { ok: false, detail: "not sent" }; @@ -211,6 +214,7 @@ async function sendApp(event, app, fetchImpl = fetch, options = {}) { 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; } @@ -371,6 +375,13 @@ function recentLog(home, count) { return []; } } +function lastSend(home) { + 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; +} async function sendNow(home, event, fetchImpl) { const config = loadConfig(home); if (!config) return { ok: false, detail: "not set up: run `code-alarm init` or `code-alarm pair`" }; @@ -1893,7 +1904,7 @@ function qrForTerminal(text) { } // src/main.ts -var VERSION = true ? "0.2.0" : "dev"; +var VERSION = true ? "0.2.1" : "dev"; var DEFAULT_ARM_MS = 12 * HOUR; var HELP = `code-alarm ${VERSION}: a real alarm on your phone when Claude Code or Codex finishes or needs you. @@ -2138,6 +2149,12 @@ Then connect your agent: const log = recentLog(home, 5); if (log.length) console.log(`Last activity: ${log.map((line) => ` ${line}`).join("\n")}`); + if (config?.transport === "app" && config.app) { + 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.");