ntfyx 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -1,37 +1,43 @@
1
1
  # Ntfyx
2
2
 
3
- End-to-end encrypted notifications and one-time replies for command-line tools and AI agents.
3
+ Send task completion notifications and answer agent questions from your phone.
4
+ Agent and CLI messages are end-to-end encrypted. A Ntfyx phone app and an approved
5
+ phone pairing are required before sending messages or using the MCP tools.
4
6
 
5
- ## Install
6
-
7
- Requires Node.js 24 or newer. Supports macOS and Linux with glibc on Apple Silicon/ARM64 and x64. Windows and Linux with musl are not supported by this release.
8
-
9
- ```sh
10
- npm install -g ntfyx
11
- ntfyx --help
12
- ntfyx version --json
13
- ```
14
-
15
- The npm package downloads the matching official binary from `https://ntfyx.me/releases/0.1.4/` during installation. It verifies the release manifest signature, download size, SHA-256 digest, and binary version before installing. An internet connection and permission to run the package's installation script are required. If installation scripts are disabled, follow the [standalone installation instructions](https://ntfyx.me/install).
16
-
17
- ## Get started
18
-
19
- Install the Ntfyx phone app, then pair this computer by scanning the QR code and approving it on your phone:
7
+ Requires Node.js 24 or later and macOS (Apple silicon or Intel) or Linux glibc
8
+ (x64 or arm64). Windows and musl Linux are not supported. Installation downloads
9
+ the version-pinned binary from ntfyx.me and verifies its release signature and hash.
20
10
 
21
11
  ```sh
12
+ npm install -g ntfyx@0.1.6
22
13
  ntfyx connect
23
- ntfyx send "Build finished"
24
- ntfyx ask "Continue?" --allow-deny --async --json
14
+ ntfyx send "Task finished"
15
+ ntfyx ask "Continue?" --allow-deny
25
16
  ```
26
17
 
27
- Use the returned request ID to read a reply:
18
+ Scan the connection QR code in Ntfyx and approve the connection on your phone.
19
+ Keep the resulting local credentials on that machine.
28
20
 
29
- ```sh
30
- ntfyx result REQUEST_ID --json
31
- ```
21
+ For an MCP client, first pair using `npx ntfyx@0.1.6 connect`, then configure:
32
22
 
33
- See the [documentation](https://ntfyx.me/docs/) for setup, agent integrations, and security details.
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "ntfyx": {
27
+ "command": "npx",
28
+ "args": ["-y", "ntfyx@0.1.6", "mcp"]
29
+ }
30
+ }
31
+ }
32
+ ```
34
33
 
35
- ## License
34
+ The MCP server uses local stdio and the local paired identity. It does not offer
35
+ a hosted MCP endpoint. Native tool approval is separate from MCP questions:
36
+ Apple silicon Mac validation covers Claude Code 2.1.303 and Codex 0.163.0 with
37
+ CLI 0.1.5 or later; Intel Mac native approval remains unverified and returns to
38
+ the terminal. Claude permission hooks require explicit `--permissions on` during
39
+ setup. Codex requires interactive TUI and `/hooks` trust; `codex exec` does not
40
+ trigger permission hooks.
36
41
 
37
- Ntfyx is distributed under the included proprietary [LICENSE](LICENSE). Third-party components retain their own licenses; see `THIRD_PARTY_NOTICES.txt` and `RELINKING.md`. The package includes `ntfyx-runtime-entry.mjs` for the runtime replacement described there.
42
+ [Installation and signed downloads](https://ntfyx.me/install) ·
43
+ [Agent setup and limits](https://ntfyx.me/docs/agents)
package/installer.mjs CHANGED
@@ -208,7 +208,6 @@ var schemas = {
208
208
  }
209
209
  })
210
210
  };
211
-
212
211
  // packages/protocol/src/index.ts
213
212
  var LIMITS = Object.freeze({
214
213
  free_topics: 1,
@@ -218,6 +217,12 @@ var LIMITS = Object.freeze({
218
217
  sources_per_inbox: 100,
219
218
  browsers: 2,
220
219
  messages_per_day: 1000,
220
+ monitor_free_messages_per_day: 100,
221
+ monitor_ten_messages_per_day: 480,
222
+ monitor_unlimited_messages_per_day: 4800,
223
+ monitor_free_bytes: 1 * 1024 * 1024,
224
+ monitor_ten_bytes: 8 * 1024 * 1024,
225
+ monitor_unlimited_bytes: 64 * 1024 * 1024,
221
226
  requests_per_day: 200,
222
227
  pending: 50,
223
228
  payload_bytes: 16384,
@@ -292,24 +297,39 @@ async function installVerified(options) {
292
297
  await mkdir(dirname(options.target), { recursive: true });
293
298
  const directory = await mkdtemp(join(dirname(options.target), ".ntfyx-install-"));
294
299
  try {
295
- const response = await (options.fetcher ?? fetch)(artifact.url, {
296
- redirect: "error",
297
- signal: AbortSignal.timeout(60000)
298
- });
299
- if (!response.ok)
300
- throw new NtfyxError("network_error", 503);
301
- const reader = response.body.getReader(), chunks = [];
302
- let length = 0;
303
- while (true) {
304
- const { done, value } = await reader.read();
305
- if (done)
306
- break;
307
- length += value.length;
308
- if (length > artifact.bytes) {
309
- await reader.cancel();
310
- throw new NtfyxError("invalid_signature");
300
+ const stalled = new AbortController, chunks = [];
301
+ let length = 0, timer;
302
+ const arm = () => {
303
+ clearTimeout(timer);
304
+ timer = setTimeout(() => stalled.abort(), 60000);
305
+ };
306
+ try {
307
+ arm();
308
+ const response = await (options.fetcher ?? fetch)(artifact.url, {
309
+ redirect: "error",
310
+ signal: AbortSignal.any([stalled.signal, AbortSignal.timeout(1800000)])
311
+ });
312
+ if (!response.ok)
313
+ throw new NtfyxError("network_error", 503);
314
+ const reader = response.body.getReader();
315
+ while (true) {
316
+ const { done, value } = await reader.read();
317
+ if (done)
318
+ break;
319
+ arm();
320
+ length += value.length;
321
+ if (length > artifact.bytes) {
322
+ await reader.cancel();
323
+ throw new NtfyxError("invalid_signature");
324
+ }
325
+ chunks.push(value);
311
326
  }
312
- chunks.push(value);
327
+ } catch (error) {
328
+ if (error instanceof NtfyxError)
329
+ throw error;
330
+ throw new NtfyxError("network_error", 503);
331
+ } finally {
332
+ clearTimeout(timer);
313
333
  }
314
334
  const binary = Buffer.concat(chunks);
315
335
  if (length !== artifact.bytes || createHash("sha256").update(binary).digest("hex") !== artifact.sha256)
@@ -3285,6 +3285,11 @@ var init_schemas = __esm(() => {
3285
3285
  };
3286
3286
  });
3287
3287
 
3288
+ // packages/protocol/src/http-send-url.ts
3289
+ var init_http_send_url = __esm(() => {
3290
+ init_src();
3291
+ });
3292
+
3288
3293
  // packages/protocol/src/index.ts
3289
3294
  function validate(name, value) {
3290
3295
  if (!exports_validators[name]?.(value))
@@ -3333,6 +3338,7 @@ var LIMITS, NtfyxError, EXIT_CODES;
3333
3338
  var init_src = __esm(() => {
3334
3339
  init_validators();
3335
3340
  init_schemas();
3341
+ init_http_send_url();
3336
3342
  LIMITS = Object.freeze({
3337
3343
  free_topics: 1,
3338
3344
  plus_topics: 20,
@@ -3341,6 +3347,12 @@ var init_src = __esm(() => {
3341
3347
  sources_per_inbox: 100,
3342
3348
  browsers: 2,
3343
3349
  messages_per_day: 1000,
3350
+ monitor_free_messages_per_day: 100,
3351
+ monitor_ten_messages_per_day: 480,
3352
+ monitor_unlimited_messages_per_day: 4800,
3353
+ monitor_free_bytes: 1 * 1024 * 1024,
3354
+ monitor_ten_bytes: 8 * 1024 * 1024,
3355
+ monitor_unlimited_bytes: 64 * 1024 * 1024,
3344
3356
  requests_per_day: 200,
3345
3357
  pending: 50,
3346
3358
  payload_bytes: 16384,
@@ -14239,24 +14251,39 @@ async function installVerified(options) {
14239
14251
  await mkdir6(dirname4(options.target), { recursive: true });
14240
14252
  const directory = await mkdtemp2(join8(dirname4(options.target), ".ntfyx-install-"));
14241
14253
  try {
14242
- const response = await (options.fetcher ?? fetch)(artifact.url, {
14243
- redirect: "error",
14244
- signal: AbortSignal.timeout(60000)
14245
- });
14246
- if (!response.ok)
14247
- throw new NtfyxError("network_error", 503);
14248
- const reader = response.body.getReader(), chunks = [];
14249
- let length = 0;
14250
- while (true) {
14251
- const { done, value } = await reader.read();
14252
- if (done)
14253
- break;
14254
- length += value.length;
14255
- if (length > artifact.bytes) {
14256
- await reader.cancel();
14257
- throw new NtfyxError("invalid_signature");
14254
+ const stalled = new AbortController, chunks = [];
14255
+ let length = 0, timer;
14256
+ const arm = () => {
14257
+ clearTimeout(timer);
14258
+ timer = setTimeout(() => stalled.abort(), 60000);
14259
+ };
14260
+ try {
14261
+ arm();
14262
+ const response = await (options.fetcher ?? fetch)(artifact.url, {
14263
+ redirect: "error",
14264
+ signal: AbortSignal.any([stalled.signal, AbortSignal.timeout(1800000)])
14265
+ });
14266
+ if (!response.ok)
14267
+ throw new NtfyxError("network_error", 503);
14268
+ const reader = response.body.getReader();
14269
+ while (true) {
14270
+ const { done, value } = await reader.read();
14271
+ if (done)
14272
+ break;
14273
+ arm();
14274
+ length += value.length;
14275
+ if (length > artifact.bytes) {
14276
+ await reader.cancel();
14277
+ throw new NtfyxError("invalid_signature");
14278
+ }
14279
+ chunks.push(value);
14258
14280
  }
14259
- chunks.push(value);
14281
+ } catch (error) {
14282
+ if (error instanceof NtfyxError)
14283
+ throw error;
14284
+ throw new NtfyxError("network_error", 503);
14285
+ } finally {
14286
+ clearTimeout(timer);
14260
14287
  }
14261
14288
  const binary = Buffer.concat(chunks);
14262
14289
  if (length !== artifact.bytes || createHash("sha256").update(binary).digest("hex") !== artifact.sha256)
@@ -14315,15 +14342,23 @@ async function upgrade() {
14315
14342
  if (!RELEASE_PUBLIC_KEY)
14316
14343
  throw new NtfyxError("release_unavailable", 503, "Production release signing key is not configured");
14317
14344
  const origin = "https://ntfyx.me/releases/stable";
14318
- const [m, s] = await Promise.all([
14319
- fetch(origin + "/manifest.json", { redirect: "error", signal: AbortSignal.timeout(30000) }),
14320
- fetch(origin + "/manifest.sig", { redirect: "error", signal: AbortSignal.timeout(30000) })
14321
- ]);
14322
- if (!m.ok || !s.ok)
14345
+ let raw, signature;
14346
+ try {
14347
+ const [m, s] = await Promise.all([
14348
+ fetch(origin + "/manifest.json", { redirect: "error", signal: AbortSignal.timeout(30000) }),
14349
+ fetch(origin + "/manifest.sig", { redirect: "error", signal: AbortSignal.timeout(30000) })
14350
+ ]);
14351
+ if (!m.ok || !s.ok)
14352
+ throw new NtfyxError("network_error", 503);
14353
+ [raw, signature] = [await m.text(), (await s.text()).trim()];
14354
+ } catch (error) {
14355
+ if (error instanceof NtfyxError)
14356
+ throw error;
14323
14357
  throw new NtfyxError("network_error", 503);
14358
+ }
14324
14359
  return installVerified({
14325
- raw: await m.text(),
14326
- signature: (await s.text()).trim(),
14360
+ raw,
14361
+ signature,
14327
14362
  publicKey: RELEASE_PUBLIC_KEY,
14328
14363
  target: process.execPath,
14329
14364
  platform: process.platform + "-" + process.arch
@@ -14339,6 +14374,34 @@ var init_upgrade = __esm(() => {
14339
14374
  init_installer();
14340
14375
  });
14341
14376
 
14377
+ // packages/adapters/src/lifecycle.ts
14378
+ function hookTransaction(db, operation) {
14379
+ db.exec("BEGIN IMMEDIATE");
14380
+ try {
14381
+ const result = operation();
14382
+ db.exec("COMMIT");
14383
+ return result;
14384
+ } catch (error) {
14385
+ db.exec("ROLLBACK");
14386
+ throw error;
14387
+ }
14388
+ }
14389
+ function ensureHookEpoch(db) {
14390
+ hookTransaction(db, () => {
14391
+ const columns = db.prepare("PRAGMA table_info(hook_sessions)").all();
14392
+ if (!columns.some((column) => column.name === "epoch"))
14393
+ db.exec("ALTER TABLE hook_sessions ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
14394
+ });
14395
+ }
14396
+ function hookEpoch(db, session) {
14397
+ const row = db.prepare("SELECT epoch FROM hook_sessions WHERE session_hash=?").get(session);
14398
+ return row.epoch;
14399
+ }
14400
+ function sameHookEpoch(db, session, epoch) {
14401
+ const row = db.prepare("SELECT closed,epoch FROM hook_sessions WHERE session_hash=?").get(session);
14402
+ return !!row && !row.closed && row.epoch === epoch;
14403
+ }
14404
+
14342
14405
  // packages/client-core/src/stream.ts
14343
14406
  class SyncHints {
14344
14407
  socket;
@@ -16807,9 +16870,18 @@ async function api(origin, path, token, body, method, signal, inbox) {
16807
16870
  }
16808
16871
  async function createPairing(origin, role, intent, options = {}) {
16809
16872
  assertOrigin(origin);
16873
+ if (options.identity) {
16874
+ if (role !== "browser" || intent !== "web-connect" || options.extractable)
16875
+ throw new NtfyxError("invalid_binding");
16876
+ const { signing, hpke, principal_id, key_id } = options.identity;
16877
+ if (signing?.privateKey?.extractable || hpke?.privateKey?.extractable)
16878
+ throw new NtfyxError("private_key_exportable");
16879
+ if (!/^[A-Za-z0-9_-]{22,80}$/.test(principal_id) || !/^[A-Za-z0-9_-]{22,80}$/.test(key_id) || signing?.privateKey?.type !== "private" || hpke?.privateKey?.type !== "private" || signing?.publicKey?.type !== "public" || hpke?.publicKey?.type !== "public" || signing.privateKey.algorithm.name !== "ECDSA" || hpke.privateKey.algorithm.name !== "ECDH" || signing.privateKey.algorithm.namedCurve !== "P-256" || hpke.privateKey.algorithm.namedCurve !== "P-256" || !signing.privateKey.usages.includes("sign") || !hpke.privateKey.usages.includes("deriveBits"))
16880
+ throw new NtfyxError("invalid_identity");
16881
+ }
16810
16882
  const config = await api(origin, "/service-config");
16811
16883
  const server_offset = config.server_time - Date.now();
16812
- const identity = await newIdentity(options.extractable ?? false);
16884
+ const identity = options.identity ?? await newIdentity(options.extractable ?? false);
16813
16885
  const pub = await publicIdentity(identity);
16814
16886
  const ephemeral = await suite.kem.generateKeyPair();
16815
16887
  const qr = {
@@ -17454,7 +17526,7 @@ function hostAnswer(q, answer) {
17454
17526
  }
17455
17527
  return value;
17456
17528
  }
17457
- async function handleClaudeQuestion(store, raw) {
17529
+ async function handleClaudeQuestion(store, raw, topic) {
17458
17530
  let ctx;
17459
17531
  let pending;
17460
17532
  let invocation;
@@ -17487,14 +17559,16 @@ async function handleClaudeQuestion(store, raw) {
17487
17559
  if (Buffer.byteLength(JSON.stringify(candidate)) > LIMITS.payload_bytes)
17488
17560
  return;
17489
17561
  }
17490
- ctx = await openClient(store);
17562
+ ctx = await openClient(store, topic);
17491
17563
  const db = ctx.ledger.db, session = await hash(data.session_id);
17492
17564
  db.exec("CREATE TABLE IF NOT EXISTS hook_sessions(session_hash TEXT PRIMARY KEY,closed INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS hook_invocations(invocation_id TEXT PRIMARY KEY,session_hash TEXT NOT NULL,request_id TEXT NOT NULL,pid INTEGER NOT NULL,parent_pid INTEGER NOT NULL,state TEXT NOT NULL); CREATE TABLE IF NOT EXISTS question_batches(invocation_id TEXT PRIMARY KEY);");
17565
+ ensureHookEpoch(db);
17493
17566
  db.prepare("INSERT OR IGNORE INTO hook_sessions(session_hash,closed) VALUES(?,0)").run(session);
17494
- const batch = await hash(data.session_id + "\x00" + data.tool_use_id);
17567
+ const epoch = hookEpoch(db, session);
17568
+ const batch = await hash(data.session_id + "\x00" + data.tool_use_id + "\x00" + epoch);
17495
17569
  if (!db.prepare("INSERT OR IGNORE INTO question_batches VALUES(?)").run(batch).changes)
17496
17570
  return;
17497
- const active = () => alive() && !db.prepare("SELECT closed FROM hook_sessions WHERE session_hash=?").get(session)?.closed && (!invocation || db.prepare("SELECT state FROM hook_invocations WHERE invocation_id=?").get(invocation)?.state === "waiting");
17571
+ const active = () => alive() && sameHookEpoch(db, session, epoch) && (!invocation || db.prepare("SELECT state FROM hook_invocations WHERE invocation_id=?").get(invocation)?.state === "waiting");
17498
17572
  monitor = setInterval(() => {
17499
17573
  if (!active())
17500
17574
  stop();
@@ -17517,7 +17591,9 @@ async function handleClaudeQuestion(store, raw) {
17517
17591
  if (value === null)
17518
17592
  return;
17519
17593
  answers[questions[i].question] = value;
17520
- db.prepare("UPDATE hook_invocations SET state='delivered' WHERE invocation_id=?").run(invocation);
17594
+ const delivered = db.prepare("UPDATE hook_invocations SET state='delivered' WHERE invocation_id=? AND state='waiting'").run(invocation);
17595
+ if (!delivered.changes || !sameHookEpoch(db, session, epoch))
17596
+ return;
17521
17597
  pending = undefined;
17522
17598
  invocation = undefined;
17523
17599
  }
@@ -17900,7 +17976,7 @@ var MCP_TOOLS = [
17900
17976
  }));
17901
17977
  var inputValidator = new import_ajv.default({ strict: false });
17902
17978
  var inputSchemas = Object.fromEntries(MCP_TOOLS.map((t) => [t.name, inputValidator.compile(t.inputSchema)]));
17903
- async function runMcp(store) {
17979
+ async function runMcp(store, defaultTopic) {
17904
17980
  const active = new Map;
17905
17981
  const output = (v) => process.stdout.write(JSON.stringify(v) + `
17906
17982
  `);
@@ -17934,7 +18010,7 @@ async function runMcp(store) {
17934
18010
  protocolVersion: "2025-11-25",
17935
18011
  capabilities: { tools: {} },
17936
18012
  instructions: "Use ntfyx_ask for user questions, choices and missing information when phone replies are enabled. Read pending requests with ntfyx_result(consume=true); continue only after an answered result with claim_acquired. Keep one session_id for the current host session. Respect the user\u2019s channel choice and existing authorization; do not ask again for an already authorized step.",
17937
- serverInfo: { name: "ntfyx", version: "0.1.4" }
18013
+ serverInfo: { name: "ntfyx", version: "0.1.6" }
17938
18014
  });
17939
18015
  return;
17940
18016
  }
@@ -17965,7 +18041,7 @@ async function runMcp(store) {
17965
18041
  const seconds = a.wait_seconds ?? 30;
17966
18042
  if (!Number.isInteger(seconds) || seconds < 0 || seconds > 45)
17967
18043
  throw new NtfyxError("invalid_duration");
17968
- context = await openClient(store, a.topic);
18044
+ context = await openClient(store, a.topic ?? defaultTopic);
17969
18045
  const { client, ledger } = context;
17970
18046
  const sessionId = a.session_id ?? process.env.NTFYX_SESSION_ID;
17971
18047
  const task = ["ntfyx_send", "ntfyx_ask"].includes(name) ? await sessionTask(sessionId, a.session_name ?? process.env.NTFYX_SESSION_NAME, a.task) : undefined;
@@ -18148,10 +18224,12 @@ var compatibility_default = {
18148
18224
  min_version: "2.1.283",
18149
18225
  verified_versions: [
18150
18226
  "2.1.283",
18151
- "2.1.284"
18227
+ "2.1.284",
18228
+ "2.1.303"
18152
18229
  ],
18153
18230
  os: [
18154
- "linux-x64"
18231
+ "linux-x64",
18232
+ "darwin-arm64"
18155
18233
  ],
18156
18234
  tools: [
18157
18235
  "Bash",
@@ -18167,21 +18245,24 @@ var compatibility_default = {
18167
18245
  unsupported: [
18168
18246
  "Interactive terminal-choice precedence and terminal UI takeover require a separate real interactive acceptance run."
18169
18247
  ],
18170
- tested_at: "2026-09-30",
18248
+ tested_at: "2026-10-19",
18171
18249
  evidence: [
18172
18250
  "docs/native-agent-evidence.json",
18173
18251
  "docs/native-agent-regression-evidence.json",
18174
- "docs/evidence/native-hosts/claude-2.1.284-basic.json"
18252
+ "docs/evidence/native-hosts/claude-2.1.284-basic.json",
18253
+ "docs/evidence/native-hosts/claude-2.1.303-darwin-arm64.json"
18175
18254
  ]
18176
18255
  },
18177
18256
  {
18178
18257
  host: "codex",
18179
18258
  min_version: "0.158.0",
18180
18259
  verified_versions: [
18181
- "0.158.0"
18260
+ "0.158.0",
18261
+ "0.163.0"
18182
18262
  ],
18183
18263
  os: [
18184
- "linux-x64"
18264
+ "linux-x64",
18265
+ "darwin-arm64"
18185
18266
  ],
18186
18267
  tools: [
18187
18268
  "Bash",
@@ -18198,9 +18279,10 @@ var compatibility_default = {
18198
18279
  "codex exec disables approvals, so PermissionRequest hooks never run there.",
18199
18280
  "Codex requires the user to review and trust hooks with /hooks before they run."
18200
18281
  ],
18201
- tested_at: "2026-09-30",
18282
+ tested_at: "2026-10-19",
18202
18283
  evidence: [
18203
- "docs/evidence/native-hosts/codex-0.158.0-basic.json"
18284
+ "docs/evidence/native-hosts/codex-0.158.0-basic.json",
18285
+ "docs/evidence/native-hosts/codex-0.163.0-darwin-arm64.json"
18204
18286
  ]
18205
18287
  }
18206
18288
  ],
@@ -18290,6 +18372,7 @@ function permissionContext(host, data) {
18290
18372
  // packages/adapters/src/claude.ts
18291
18373
  var quote = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
18292
18374
  var CLAUDE_HOOKS = [
18375
+ ["SessionStart", "session-start", ""],
18293
18376
  ["PreToolUse", "question", "AskUserQuestion"],
18294
18377
  ["PermissionRequest", "permission", "Bash|Edit|Write"],
18295
18378
  ["Notification", "notification", "permission_prompt|elicitation_dialog"],
@@ -18306,7 +18389,12 @@ function mergeHooks(settings, executable, remove = false, spec = CLAUDE_HOOKS, s
18306
18389
  const entries = updated.hooks[event] ?? [];
18307
18390
  if (!Array.isArray(entries))
18308
18391
  throw new NtfyxError("local_store");
18309
- updated.hooks[event] = entries.filter((x) => !(Array.isArray(x.hooks) && x.hooks.length === 1 && commands.includes(x.hooks[0].command)));
18392
+ updated.hooks[event] = entries.map((entry) => {
18393
+ if (!entry || !Array.isArray(entry.hooks))
18394
+ return entry;
18395
+ const hooks = entry.hooks.filter((hook) => !commands.includes(hook?.command));
18396
+ return hooks.length ? { ...entry, hooks } : undefined;
18397
+ }).filter((entry) => entry !== undefined);
18310
18398
  if (!remove)
18311
18399
  updated.hooks[event].push({
18312
18400
  ...matcher ? { matcher } : {},
@@ -18349,8 +18437,29 @@ async function setupClaude(options) {
18349
18437
  if (e.code !== "ENOENT")
18350
18438
  throw new NtfyxError("local_store", 400, "Cannot parse Claude settings");
18351
18439
  }
18352
- const suffix = options.config ? " --config " + quote(options.config) : "";
18353
- const updated = mergeHooks(settings, options.executable, options.remove, CLAUDE_HOOKS, suffix);
18440
+ const suffix = (options.config ? " --config " + quote(options.config) : "") + (options.topic ? " --topic " + quote(options.topic) : "");
18441
+ const permissions = options.permissions ?? false;
18442
+ const questions = options.questions ?? true;
18443
+ const notifications = options.notifications ?? true;
18444
+ const lifecycle = permissions || questions;
18445
+ const selected = CLAUDE_HOOKS.filter(([event]) => {
18446
+ if (event === "PermissionRequest")
18447
+ return permissions;
18448
+ if (event === "PreToolUse")
18449
+ return questions;
18450
+ if (event === "SessionStart" || event === "SessionEnd")
18451
+ return lifecycle;
18452
+ if (event === "Stop")
18453
+ return notifications || lifecycle;
18454
+ return notifications;
18455
+ }).map(([event, hook, matcher]) => [
18456
+ event,
18457
+ event === "Stop" && !notifications ? "stop-cleanup" : hook,
18458
+ matcher
18459
+ ]);
18460
+ const managed = [...CLAUDE_HOOKS, ["Stop", "stop-cleanup", ""]];
18461
+ const cleaned = mergeHooks(settings, options.executable, true, managed, suffix);
18462
+ const updated = options.remove ? cleaned : mergeHooks(cleaned, options.executable, false, selected, suffix);
18354
18463
  const mcpPath = options.directory ? join5(directory, "mcp-test.json") : join5(homedir2(), ".claude.json");
18355
18464
  let mcp = {};
18356
18465
  try {
@@ -18360,7 +18469,7 @@ async function setupClaude(options) {
18360
18469
  throw new NtfyxError("local_store");
18361
18470
  }
18362
18471
  mcp.mcpServers ??= {};
18363
- const owned = { command: options.executable, args: ["mcp", ...options.config ? ["--config", options.config] : []] };
18472
+ const owned = { command: options.executable, args: ["mcp", ...options.config ? ["--config", options.config] : [], ...options.topic ? ["--topic", options.topic] : []] };
18364
18473
  if (mcp.mcpServers.ntfyx && JSON.stringify(mcp.mcpServers.ntfyx) !== JSON.stringify(owned))
18365
18474
  throw new NtfyxError("local_store", 409, "Existing ntfyx MCP entry is not managed by this executable");
18366
18475
  if (options.remove)
@@ -18372,8 +18481,9 @@ async function setupClaude(options) {
18372
18481
  return {
18373
18482
  status: "dry_run",
18374
18483
  action: options.remove ? "remove" : "merge",
18375
- managed_hooks: mergeHooks({}, options.executable, false, CLAUDE_HOOKS, suffix).hooks,
18484
+ managed_hooks: mergeHooks({}, options.executable, false, selected, suffix).hooks,
18376
18485
  managed_mcp: { ntfyx: owned },
18486
+ components: { permissions, questions, notifications },
18377
18487
  preserves_existing_permissions: true,
18378
18488
  preserves_unrelated_hooks: true,
18379
18489
  preserves_unrelated_mcp: true
@@ -18387,10 +18497,11 @@ async function setupClaude(options) {
18387
18497
  await installGuidance(directory, options.remove);
18388
18498
  return {
18389
18499
  status: options.remove ? "removed" : "configured",
18390
- native_approval: "verified_noninteractive_experimental_interactive"
18500
+ components: { permissions, questions, notifications },
18501
+ native_approval: permissions ? "version_and_platform_gated" : "disabled"
18391
18502
  };
18392
18503
  }
18393
- async function handleHook(store, event, input, host = "claude-code") {
18504
+ async function handleHook(store, event, input, host = "claude-code", topic) {
18394
18505
  const label = HOST_LABEL[host];
18395
18506
  let ctx;
18396
18507
  let request_id;
@@ -18418,13 +18529,30 @@ async function handleHook(store, event, input, host = "claude-code") {
18418
18529
  if (typeof data.session_id !== "string" || !data.session_id)
18419
18530
  return;
18420
18531
  const task = await sessionTask(data.session_id, label + " \xB7 " + data.session_id.slice(0, 8));
18421
- ctx = await openClient(store);
18532
+ ctx = await openClient(store, topic);
18422
18533
  const db = ctx.ledger.db, session_hash = await hash(data.session_id);
18423
18534
  db.exec("CREATE TABLE IF NOT EXISTS hook_sessions(session_hash TEXT PRIMARY KEY,closed INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS hook_invocations(invocation_id TEXT PRIMARY KEY,session_hash TEXT NOT NULL,request_id TEXT NOT NULL,pid INTEGER NOT NULL,parent_pid INTEGER NOT NULL,state TEXT NOT NULL);");
18535
+ ensureHookEpoch(db);
18424
18536
  db.prepare("INSERT OR IGNORE INTO hook_sessions(session_hash,closed) VALUES(?,0)").run(session_hash);
18425
- if (event === "session-end" || event === "stop") {
18426
- if (event === "session-end")
18427
- db.prepare("UPDATE hook_sessions SET closed=1 WHERE session_hash=?").run(session_hash);
18537
+ const epoch = hookEpoch(db, session_hash);
18538
+ if (event === "session-start") {
18539
+ const prior = hookTransaction(db, () => {
18540
+ const waits = db.prepare("SELECT request_id FROM hook_invocations WHERE session_hash=? AND state='waiting'").all(session_hash);
18541
+ db.prepare("UPDATE hook_invocations SET state='abandoned' WHERE session_hash=? AND state='waiting'").run(session_hash);
18542
+ db.prepare("UPDATE hook_sessions SET closed=0,epoch=epoch+1 WHERE session_hash=?").run(session_hash);
18543
+ return waits;
18544
+ });
18545
+ for (const old of prior) {
18546
+ ctx.ledger.abandon(String(old.request_id));
18547
+ await Promise.race([
18548
+ ctx.client.cancel(String(old.request_id), "terminal_takeover").catch(() => {}),
18549
+ new Promise((resolve) => setTimeout(resolve, 1000))
18550
+ ]);
18551
+ }
18552
+ return;
18553
+ }
18554
+ if (event === "session-end" || event === "stop" || event === "stop-cleanup") {
18555
+ db.prepare(event === "session-end" ? "UPDATE hook_sessions SET epoch=epoch+1,closed=1 WHERE session_hash=?" : "UPDATE hook_sessions SET epoch=epoch+1 WHERE session_hash=?").run(session_hash);
18428
18556
  const pending = db.prepare("SELECT request_id FROM hook_invocations WHERE session_hash=? AND state='waiting'").all(session_hash);
18429
18557
  db.prepare("UPDATE hook_invocations SET state='abandoned' WHERE session_hash=? AND state='waiting'").run(session_hash);
18430
18558
  for (const p of pending) {
@@ -18434,7 +18562,7 @@ async function handleHook(store, event, input, host = "claude-code") {
18434
18562
  new Promise((r) => setTimeout(r, 1000))
18435
18563
  ]);
18436
18564
  }
18437
- if (event === "session-end")
18565
+ if (event === "session-end" || event === "stop-cleanup")
18438
18566
  return;
18439
18567
  }
18440
18568
  if (event !== "permission") {
@@ -18490,7 +18618,7 @@ async function handleHook(store, event, input, host = "claude-code") {
18490
18618
  }
18491
18619
  const q = validateQuestion(question);
18492
18620
  db.prepare("INSERT INTO hook_invocations VALUES(?,?,?,?,?,'waiting')").run(invocation_id, session_hash, request_id, process.pid, initialParent);
18493
- const active = () => parentAlive() && !abort.signal.aborted && !db.prepare("SELECT closed FROM hook_sessions WHERE session_hash=?").get(session_hash)?.closed && db.prepare("SELECT state FROM hook_invocations WHERE invocation_id=?").get(invocation_id)?.state === "waiting";
18621
+ const active = () => parentAlive() && !abort.signal.aborted && sameHookEpoch(db, session_hash, epoch) && db.prepare("SELECT state FROM hook_invocations WHERE invocation_id=?").get(invocation_id)?.state === "waiting";
18494
18622
  monitor = setInterval(() => {
18495
18623
  if (!active())
18496
18624
  interrupted();
@@ -18502,8 +18630,8 @@ async function handleHook(store, event, input, host = "claude-code") {
18502
18630
  const decision = answer.response.value;
18503
18631
  if (decision !== "allow" && decision !== "deny")
18504
18632
  return;
18505
- db.prepare("UPDATE hook_invocations SET state='delivered' WHERE invocation_id=? AND state='waiting'").run(invocation_id);
18506
- if (!parentAlive() || abort.signal.aborted)
18633
+ const deliveredRow = db.prepare("UPDATE hook_invocations SET state='delivered' WHERE invocation_id=? AND state='waiting'").run(invocation_id);
18634
+ if (!deliveredRow.changes || !parentAlive() || abort.signal.aborted || !sameHookEpoch(db, session_hash, epoch))
18507
18635
  return;
18508
18636
  process.stdout.write(JSON.stringify({
18509
18637
  hookSpecificOutput: {
@@ -18564,8 +18692,9 @@ async function setupCodex(options) {
18564
18692
  throw new NtfyxError("local_store", 400, "Cannot parse Codex hooks.json");
18565
18693
  }
18566
18694
  const suffix = CODEX_SUFFIX + (options.config ? " --config " + "'" + options.config.replace(/'/g, "'\\''") + "'" : "");
18567
- const updated = mergeHooks(hooks, options.executable, options.remove, CODEX_HOOKS, suffix);
18568
- const owned = { command: options.executable, args: ["mcp", ...options.config ? ["--config", options.config] : []] };
18695
+ const topicSuffix = options.topic ? " --topic " + "'" + options.topic.replace(/'/g, "'\\''") + "'" : "";
18696
+ const updated = mergeHooks(hooks, options.executable, options.remove, CODEX_HOOKS, suffix + topicSuffix);
18697
+ const owned = { command: options.executable, args: ["mcp", ...options.config ? ["--config", options.config] : [], ...options.topic ? ["--topic", options.topic] : []] };
18569
18698
  let existing;
18570
18699
  try {
18571
18700
  existing = JSON.parse(codex(["mcp", "get", "ntfyx", "--json"]));
@@ -18582,7 +18711,7 @@ async function setupCodex(options) {
18582
18711
  return {
18583
18712
  status: "dry_run",
18584
18713
  action: options.remove ? "remove" : "merge",
18585
- managed_hooks: mergeHooks({}, options.executable, false, CODEX_HOOKS, suffix).hooks,
18714
+ managed_hooks: mergeHooks({}, options.executable, false, CODEX_HOOKS, suffix + topicSuffix).hooks,
18586
18715
  managed_mcp: { ntfyx: owned },
18587
18716
  preserves_unrelated_hooks: true,
18588
18717
  hook_trust: "Review and trust the ntfyx hooks in Codex with /hooks before they run."
@@ -18947,46 +19076,62 @@ function codexPath() {
18947
19076
  }
18948
19077
  }
18949
19078
  async function main(args = process.argv.slice(2)) {
18950
- const leadingRequestId = ["result", "cancel"].includes(args[0] ?? "") && /^-[A-Za-z0-9_-]{21,79}$/.test(args[1] ?? "") ? args[1] : undefined;
19079
+ const options = {
19080
+ json: bool,
19081
+ "no-input": bool,
19082
+ new: bool,
19083
+ stdin: bool,
19084
+ "allow-deny": bool,
19085
+ text: bool,
19086
+ "allow-text": bool,
19087
+ async: bool,
19088
+ "keep-pending": bool,
19089
+ claim: bool,
19090
+ "dry-run": bool,
19091
+ remove: bool,
19092
+ permissions: string,
19093
+ questions: string,
19094
+ notifications: string,
19095
+ roundtrip: bool,
19096
+ topic: string,
19097
+ title: string,
19098
+ task: string,
19099
+ "session-id": string,
19100
+ sessionid: string,
19101
+ "session-name": string,
19102
+ priority: string,
19103
+ "dedupe-key": string,
19104
+ expires: string,
19105
+ wait: string,
19106
+ config: string,
19107
+ resume: string,
19108
+ qr: string,
19109
+ server: string,
19110
+ host: string,
19111
+ codex: string,
19112
+ interval: string,
19113
+ once: bool,
19114
+ choice: { type: "string", multiple: true },
19115
+ help: bool
19116
+ };
19117
+ let commandIndex = 0;
19118
+ while (commandIndex < args.length && args[commandIndex].startsWith("--")) {
19119
+ const token = args[commandIndex];
19120
+ if (token === "--")
19121
+ break;
19122
+ const name = token.slice(2).split("=", 1)[0];
19123
+ const option = options[name];
19124
+ if (!option)
19125
+ break;
19126
+ commandIndex += option.type === "string" && !token.includes("=") ? 2 : 1;
19127
+ }
19128
+ const requestIndex = commandIndex + 1;
19129
+ const leadingRequestId = ["result", "cancel"].includes(args[commandIndex] ?? "") && /^-[A-Za-z0-9_-]{21,79}$/.test(args[requestIndex] ?? "") ? args[requestIndex] : undefined;
18951
19130
  const parsed = parseArgs({
18952
- args: leadingRequestId ? [args[0], ...args.slice(2)] : args,
19131
+ args: leadingRequestId ? args.filter((_, index) => index !== requestIndex) : args,
18953
19132
  allowPositionals: true,
18954
19133
  strict: true,
18955
- options: {
18956
- json: bool,
18957
- "no-input": bool,
18958
- new: bool,
18959
- stdin: bool,
18960
- "allow-deny": bool,
18961
- text: bool,
18962
- "allow-text": bool,
18963
- async: bool,
18964
- "keep-pending": bool,
18965
- claim: bool,
18966
- "dry-run": bool,
18967
- remove: bool,
18968
- roundtrip: bool,
18969
- topic: string,
18970
- title: string,
18971
- task: string,
18972
- "session-id": string,
18973
- sessionid: string,
18974
- "session-name": string,
18975
- priority: string,
18976
- "dedupe-key": string,
18977
- expires: string,
18978
- wait: string,
18979
- config: string,
18980
- resume: string,
18981
- qr: string,
18982
- server: string,
18983
- host: string,
18984
- codex: string,
18985
- interval: string,
18986
- once: bool,
18987
- choice: { type: "string", multiple: true },
18988
- help: bool
18989
- }
19134
+ options
18990
19135
  });
18991
19136
  if (leadingRequestId)
18992
19137
  parsed.positionals.splice(1, 0, leadingRequestId);
@@ -18997,7 +19142,8 @@ async function main(args = process.argv.slice(2)) {
18997
19142
  if (f.help || !command || command === "help") {
18998
19143
  process.stdout.write(`ntfyx connect | create | send BODY | ask QUESTION --allow-deny|--choice id=label|--text
18999
19144
  ntfyx result ID [--claim] | cancel ID | topics | use ALIAS | sources | disconnect ALIAS
19000
- ntfyx doctor | setup claude-code|codex|codex-usage [--dry-run|--remove] | mcp | version | upgrade
19145
+ ntfyx doctor | setup claude-code|codex|codex-usage [--dry-run|--remove]
19146
+ setup claude-code: --permissions on|off (default off) --questions on|off --notifications on|off; --config FILE --topic ALIAS | mcp | version | upgrade
19001
19147
  send/ask: --session-id ID [--session-name NAME] (or NTFYX_SESSION_ID)
19002
19148
  ntfyx watch codex-usage [--once] [--interval 15m] [--dry-run] [--topic ALIAS]
19003
19149
  ntfyx connect --new --topic NEW_ALIAS | connect --resume ID --topic NEW_ALIAS
@@ -19008,7 +19154,7 @@ connect/create: --qr auto|terminal|file (default: auto; compact QR or private lo
19008
19154
  if (command === "version") {
19009
19155
  output({
19010
19156
  application: "ntfyx",
19011
- version: "0.1.4",
19157
+ version: "0.1.6",
19012
19158
  protocol: 1,
19013
19159
  build: "release",
19014
19160
  installation: process.env.NTFYX_INSTALL_METHOD === "npm" ? "npm" : process.versions.bun ? "binary" : "source"
@@ -19016,7 +19162,7 @@ connect/create: --qr auto|terminal|file (default: auto; compact QR or private lo
19016
19162
  return 0;
19017
19163
  }
19018
19164
  if (command === "mcp") {
19019
- await runMcp(store);
19165
+ await runMcp(store, f.topic);
19020
19166
  return 0;
19021
19167
  }
19022
19168
  if (command === "hook") {
@@ -19026,9 +19172,9 @@ connect/create: --qr auto|terminal|file (default: auto; compact QR or private lo
19026
19172
  try {
19027
19173
  const input = await stdin(128 * 1024);
19028
19174
  if (rest[0] === "question" && host === "claude-code")
19029
- await handleClaudeQuestion(store, input);
19175
+ await handleClaudeQuestion(store, input, f.topic);
19030
19176
  else
19031
- await handleHook(store, rest[0], input, host);
19177
+ await handleHook(store, rest[0], input, host, f.topic);
19032
19178
  } catch {}
19033
19179
  return 0;
19034
19180
  }
@@ -19052,11 +19198,26 @@ connect/create: --qr auto|terminal|file (default: auto; compact QR or private lo
19052
19198
  const setup = rest[0] === "claude-code" ? setupClaude : rest[0] === "codex" ? setupCodex : undefined;
19053
19199
  if (!setup)
19054
19200
  throw new NtfyxError("invalid_schema");
19201
+ const toggle = (value, fallback) => {
19202
+ if (value === undefined)
19203
+ return fallback;
19204
+ if (value !== "on" && value !== "off")
19205
+ throw new NtfyxError("invalid_schema");
19206
+ return value === "on";
19207
+ };
19208
+ if (rest[0] !== "claude-code" && [f.permissions, f.questions, f.notifications].some((v) => v !== undefined))
19209
+ throw new NtfyxError("invalid_schema");
19055
19210
  output(await setup({
19056
19211
  executable: resolve(process.execPath),
19057
19212
  dryRun: !!f["dry-run"],
19058
19213
  remove: !!f.remove,
19059
- ...f.config ? { config: store.path } : {}
19214
+ ...f.config ? { config: store.path } : {},
19215
+ ...f.topic ? { topic: f.topic } : {},
19216
+ ...rest[0] === "claude-code" ? {
19217
+ permissions: toggle(f.permissions, false),
19218
+ questions: toggle(f.questions, true),
19219
+ notifications: toggle(f.notifications, true)
19220
+ } : {}
19060
19221
  }));
19061
19222
  return 0;
19062
19223
  }
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "ntfyx",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "End-to-end encrypted agent notifications and one-time replies",
7
+ "homepage": "https://ntfyx.me/docs/agents",
8
+ "mcpName": "me.ntfyx/ntfyx",
7
9
  "engines": {
8
10
  "node": ">=24"
9
11
  },
@@ -19,6 +21,7 @@
19
21
  "installer.mjs",
20
22
  "release-public-key.pem",
21
23
  "package.json",
24
+ "README.md",
22
25
  "LICENSE",
23
26
  "THIRD_PARTY_NOTICES.txt",
24
27
  "RELINKING.md",