@paigy/mcp 0.15.0 → 0.17.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @paigy/mcp
2
2
 
3
- A voice inbox for your AI agents. This MCP server lets an agent **notify a user** and **await their reply** — so a long-running agent can ask a question, hand off, and resume on the answer.
3
+ A voice inbox for your AI agents. This MCP server lets an agent **notify a user** and **await their reply** — so a long-running agent can ask a question, hand off, and resume on the answer. It's a thin MCP-tool wrapper over `@paigy/sdk` (`packages/sdk`) — use the SDK directly from any Node process that isn't an MCP client.
4
4
 
5
5
  ## Install
6
6
 
@@ -103,6 +103,38 @@ Then pair: `npx -p @paigy/mcp@latest paigy-mcp-onboard`.
103
103
  ## Configuration
104
104
 
105
105
  - `PAIGY_BACKEND_URL` — the Paigy API base (defaults to the hosted backend).
106
+ - `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` — honored for every REST call, with
107
+ curl's semantics. Node's `fetch` ignores these (unlike curl), so the server
108
+ wires them up itself; in environments where egress only flows through a proxy
109
+ (Claude Code on the web, corporate CI), calls route through it automatically —
110
+ no extra setup. Two edges: with `HTTP_PROXY` set, an `http://localhost` backend
111
+ proxies too unless `NO_PROXY=localhost` (hostname entries work; CIDR ranges are
112
+ ignored), and `paigy-listen`'s realtime wake channel is a websocket that doesn't
113
+ proxy — its REST sweeps do.
114
+
115
+ ## Wake any harness (`paigy-listen`)
116
+
117
+ `paigy-listen` is the self-hosted push daemon: it subscribes to this connection's
118
+ wake channel and sweeps on every nudge. Keep it alive past the terminal with
119
+ `paigy-listen --install` (launchd on macOS, systemd user unit on Linux; `--uninstall`
120
+ removes it).
121
+
122
+ To launch an agent — any agent, not just Claude — when work arrives, set
123
+ `PAIGY_ON_WAKE` to a command before `--install`. The daemon runs it with the swept
124
+ work in `$PAIGY_WORK` (JSON: `replies`, `requests`, `owedCallbacks`); the sweep has
125
+ already claimed the work, so read `$PAIGY_WORK` rather than calling `check_replies`
126
+ again. A request carrying a `contextThreadId` (or an unfamiliar `threadId`) means
127
+ the user is resuming/seeding a past conversation — rehydrate with `get_thread` first.
128
+
129
+ ```sh
130
+ # Claude Code:
131
+ PAIGY_ON_WAKE='claude -p "Handle the Paigy work in $PAIGY_WORK — rehydrate threads you do not recognize via get_thread first."' \
132
+ npx -y -p @paigy/mcp paigy-listen --install
133
+
134
+ # Codex (or any CLI harness) works the same way:
135
+ PAIGY_ON_WAKE='codex exec "Handle the Paigy work in $PAIGY_WORK ..."' \
136
+ npx -y -p @paigy/mcp paigy-listen --install
137
+ ```
106
138
 
107
139
  ## Statusline (Claude Code)
108
140
 
@@ -116,7 +148,7 @@ paigy: all calls · phone ✓
116
148
  Enable it in `~/.claude/settings.json`:
117
149
 
118
150
  ```json
119
- "statusLine": { "type": "command", "command": "npx -y @paigy/mcp@latest paigy-statusline" }
151
+ "statusLine": { "type": "command", "command": "npx -y -p @paigy/mcp@latest paigy-statusline" }
120
152
  ```
121
153
 
122
154
  It reads `GET /api/status` with the pairing token and caches the result in
@@ -0,0 +1,36 @@
1
+ // src/surface.ts
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ var SURFACE_PATH = join(homedir(), ".paigy", "surface.json");
6
+ function writeSurface(label, code, ttlSeconds) {
7
+ try {
8
+ mkdirSync(join(homedir(), ".paigy"), { recursive: true });
9
+ writeFileSync(
10
+ SURFACE_PATH,
11
+ JSON.stringify({ label, code, expiresAt: Date.now() + ttlSeconds * 1e3 }),
12
+ { mode: 384 }
13
+ );
14
+ } catch {
15
+ }
16
+ }
17
+ function clearSurface() {
18
+ try {
19
+ rmSync(SURFACE_PATH, { force: true });
20
+ } catch {
21
+ }
22
+ }
23
+ function readSurface(now = Date.now) {
24
+ try {
25
+ const { label, code, expiresAt } = JSON.parse(readFileSync(SURFACE_PATH, "utf8"));
26
+ return label && code && typeof expiresAt === "number" && expiresAt > now() ? { label, code } : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export {
33
+ writeSurface,
34
+ clearSurface,
35
+ readSurface
36
+ };
@@ -1,14 +1,20 @@
1
- import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
1
+ // ../../packages/sdk/dist/index.js
2
+ import { createRequire as __sdkCreateRequire } from "module";
3
+ import { z } from "zod";
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ var require2 = __sdkCreateRequire(import.meta.url);
2
8
  var __create = Object.create;
3
9
  var __defProp = Object.defineProperty;
4
10
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
11
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
12
  var __getProtoOf = Object.getPrototypeOf;
7
13
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
9
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
14
+ var __require = /* @__PURE__ */ ((x) => typeof require2 !== "undefined" ? require2 : typeof Proxy !== "undefined" ? new Proxy(x, {
15
+ get: (a, b) => (typeof require2 !== "undefined" ? require2 : a)[b]
10
16
  }) : x)(function(x) {
11
- if (typeof require !== "undefined") return require.apply(this, arguments);
17
+ if (typeof require2 !== "undefined") return require2.apply(this, arguments);
12
18
  throw Error('Dynamic require of "' + x + '" is not supported');
13
19
  });
14
20
  var __commonJS = (cb, mod) => function __require2() {
@@ -30,8 +36,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
36
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
31
37
  mod
32
38
  ));
33
-
34
- // ../../node_modules/.pnpm/tweetnacl@1.0.3/node_modules/tweetnacl/nacl-fast.js
35
39
  var require_nacl_fast = __commonJS({
36
40
  "../../node_modules/.pnpm/tweetnacl@1.0.3/node_modules/tweetnacl/nacl-fast.js"(exports, module) {
37
41
  "use strict";
@@ -2255,9 +2259,21 @@ var require_nacl_fast = __commonJS({
2255
2259
  })(typeof module !== "undefined" && module.exports ? module.exports : self.nacl = self.nacl || {});
2256
2260
  }
2257
2261
  });
2258
-
2259
- // ../../packages/schema/dist/index.js
2260
- import { z } from "zod";
2262
+ var BACKEND_URL = process.env.PAIGY_BACKEND_URL ?? "https://paigy.ai";
2263
+ var NETWORK_MSG = `Can't reach ${BACKEND_URL} \u2014 the connection was blocked or dropped before an HTTP response. If this agent runs in a sandboxed environment with a network allowlist (e.g. Claude Code on the web, CI), ask the user to add the Paigy domain (paigy.ai) to the environment's allowed domains, then retry.`;
2264
+ var PROXY_ENV = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
2265
+ var agent;
2266
+ async function proxy() {
2267
+ if (!PROXY_ENV.some((k) => process.env[k])) return void 0;
2268
+ return agent ??= new (await import("undici")).EnvHttpProxyAgent();
2269
+ }
2270
+ async function reach(url, init) {
2271
+ try {
2272
+ return await fetch(url, { ...init, dispatcher: await proxy() });
2273
+ } catch (e) {
2274
+ throw new Error(`${NETWORK_MSG} (${e?.message ?? String(e)})`);
2275
+ }
2276
+ }
2261
2277
  var ContextSchema = z.object({
2262
2278
  title: z.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
2263
2279
  description: z.array(z.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
@@ -2488,7 +2504,15 @@ var PendingRepliesSchema = z.object({
2488
2504
  * notify_user on the same threadId. Keeps reappearing until you call
2489
2505
  * set_task_state on its notificationId. */
2490
2506
  requests: z.array(
2491
- z.object({ threadId: z.string(), notificationId: z.string(), text: z.string(), createdAt: z.string() })
2507
+ z.object({
2508
+ threadId: z.string(),
2509
+ notificationId: z.string(),
2510
+ text: z.string(),
2511
+ createdAt: z.string(),
2512
+ /** The user seeded this request with a past conversation — call get_thread on it
2513
+ * FIRST and treat the transcript as prior context (#57/#251). */
2514
+ contextThreadId: z.string().optional()
2515
+ })
2492
2516
  ),
2493
2517
  /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
2494
2518
  * if blocked, or at a time that has passed). Re-surfaced every sweep until you
@@ -2524,8 +2548,13 @@ var UserResponseSchema = z.object({
2524
2548
  /** Coverage report (#396): which of the ask's declared `points` were addressed. */
2525
2549
  covered: z.array(z.string()).optional()
2526
2550
  });
2551
+ var VoiceKeySchema = z.enum(["rachel", "george", "jessica", "brian", "lily"]);
2527
2552
  var InboxItemSchema = z.object({
2528
2553
  id: z.string(),
2554
+ /** The conversation thread + connection this item lives on. Present on the replied
2555
+ * detail — they power History's "Continue" / "New session from this" (#57/#251). */
2556
+ threadId: z.string().optional(),
2557
+ tokenId: z.string().optional(),
2529
2558
  status: NotifyStatusSchema,
2530
2559
  context: ContextSchema,
2531
2560
  options: z.array(OptionSchema).optional(),
@@ -2540,6 +2569,8 @@ var InboxItemSchema = z.object({
2540
2569
  visuals: z.array(VisualSchema).optional(),
2541
2570
  agent: z.string(),
2542
2571
  nickname: z.string(),
2572
+ /** The pairing's assigned voice (#462); absent = the default voice. */
2573
+ voice: VoiceKeySchema.optional(),
2543
2574
  repo: z.string().optional(),
2544
2575
  branch: z.string().optional(),
2545
2576
  createdAt: z.string().datetime(),
@@ -2659,6 +2690,8 @@ var ConnectionSummarySchema = z.object({
2659
2690
  agent: z.string(),
2660
2691
  device: z.string().nullable(),
2661
2692
  nickname: z.string(),
2693
+ /** The pairing's assigned voice (#462); null = the default voice. */
2694
+ voice: VoiceKeySchema.nullable(),
2662
2695
  createdAt: z.string().datetime(),
2663
2696
  /** Most recent notification on this connection, either direction. Null = no contact yet.
2664
2697
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -2671,11 +2704,16 @@ var CreateRequestSchema = z.object({
2671
2704
  /** The connection (token id) to send to, from GET /api/tokens. */
2672
2705
  tokenId: z.string(),
2673
2706
  /** The user's message to the agent. */
2674
- text: z.string().min(1)
2707
+ text: z.string().min(1),
2708
+ /** Land the request on an existing conversation thread (History → "Continue")
2709
+ * instead of minting a fresh one. Must belong to the requesting user. */
2710
+ threadId: z.string().optional(),
2711
+ /** Point the agent at a past conversation (possibly with a different agent) as
2712
+ * starting context (History → "New session from this"). A reference, not a copy —
2713
+ * the agent reads it via get_thread. Must belong to the requesting user. */
2714
+ contextThreadId: z.string().optional()
2675
2715
  });
2676
2716
  var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
2677
- var WAKE_EVENT = "wake";
2678
- var wakeChannel = (tokenId) => `wake:${tokenId}`;
2679
2717
  var RegisterDeliverySchema = z.object({ mode: DeliveryModeSchema });
2680
2718
  var OAuthStartSchema = z.object({
2681
2719
  provider: z.enum(["cma"]),
@@ -2812,25 +2850,60 @@ var SupportRequestSchema = z.object({
2812
2850
  message: z.string().trim().min(1).max(5e3),
2813
2851
  name: z.string().trim().max(120).optional()
2814
2852
  });
2815
-
2816
- // src/http.ts
2817
- var BACKEND_URL = process.env.PAIGY_BACKEND_URL ?? "https://paigy.ai";
2818
- var NETWORK_MSG = `Can't reach ${BACKEND_URL} \u2014 the connection was blocked or dropped before an HTTP response. If this agent runs in a sandboxed environment with a network allowlist (e.g. Claude Code on the web, CI), ask the user to add the Paigy domain (paigy.ai) to the environment's allowed domains, then retry.`;
2819
- async function reach(url, init) {
2820
- try {
2821
- return await fetch(url, init);
2822
- } catch (e) {
2823
- throw new Error(`${NETWORK_MSG} (${e?.message ?? String(e)})`);
2853
+ var WORD_NUMS = {
2854
+ a: 1,
2855
+ an: 1,
2856
+ one: 1,
2857
+ two: 2,
2858
+ three: 3,
2859
+ four: 4,
2860
+ five: 5,
2861
+ six: 6,
2862
+ seven: 7,
2863
+ eight: 8,
2864
+ nine: 9,
2865
+ ten: 10,
2866
+ fifteen: 15,
2867
+ twenty: 20,
2868
+ thirty: 30,
2869
+ forty: 40,
2870
+ "forty-five": 45,
2871
+ fifty: 50,
2872
+ sixty: 60,
2873
+ "half an": 0.5,
2874
+ "a couple of": 2,
2875
+ "a few": 3
2876
+ };
2877
+ function untilHour(hour, now) {
2878
+ const due = new Date(now);
2879
+ due.setHours(hour, 0, 0, 0);
2880
+ if (due.getTime() <= now.getTime()) due.setDate(due.getDate() + 1);
2881
+ return Math.round((due.getTime() - now.getTime()) / 1e3);
2882
+ }
2883
+ function parseDue(detail, now = /* @__PURE__ */ new Date()) {
2884
+ const t = detail.toLowerCase().trim();
2885
+ const rel = /(?:^|\b)in\s+(half an|a couple of|a few|an?|[\w-]+|\d+)\s*(minutes?|mins?|hours?|hrs?)\b/.exec(t);
2886
+ if (rel) {
2887
+ const n = /^\d+$/.test(rel[1]) ? Number(rel[1]) : WORD_NUMS[rel[1]];
2888
+ if (n !== void 0) {
2889
+ const unit = rel[2].startsWith("h") ? 3600 : 60;
2890
+ return Math.round(n * unit);
2891
+ }
2892
+ }
2893
+ if (/after lunch|lunchtime/.test(t)) return untilHour(13, now);
2894
+ if (/tomorrow/.test(t)) {
2895
+ const due = new Date(now);
2896
+ due.setDate(due.getDate() + 1);
2897
+ due.setHours(9, 0, 0, 0);
2898
+ return Math.round((due.getTime() - now.getTime()) / 1e3);
2899
+ }
2900
+ if (/tonight|this evening/.test(t)) {
2901
+ return now.getHours() < 20 ? untilHour(20, now) : 3600;
2824
2902
  }
2903
+ if (/end of (the )?day|eod/.test(t)) return untilHour(17, now);
2904
+ if (/later|in a (bit|while)/.test(t)) return 1800;
2905
+ return null;
2825
2906
  }
2826
-
2827
- // src/device.ts
2828
- import { execFile } from "child_process";
2829
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
2830
- import { homedir, platform } from "os";
2831
- import { join, dirname } from "path";
2832
-
2833
- // ../../packages/crypto/dist/index.js
2834
2907
  var import_tweetnacl = __toESM(require_nacl_fast(), 1);
2835
2908
  var import_tweetnacl2 = __toESM(require_nacl_fast(), 1);
2836
2909
  var import_tweetnacl3 = __toESM(require_nacl_fast(), 1);
@@ -3099,45 +3172,11 @@ function verifyPairingCommit(commitment, proto, role, x25519Pub, ed25519Pub, non
3099
3172
  return false;
3100
3173
  }
3101
3174
  }
3102
-
3103
- // src/device.ts
3104
3175
  var AGENT_NAME = process.env.PAIGY_AGENT ?? "mcp-agent";
3105
3176
  var PROTO = "paigy-pair-v2|sas=24";
3106
3177
  var SAS_BITS = 24;
3107
3178
  var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
3108
3179
  var KEY_PATH = join(homedir(), ".paigy", "key.json");
3109
- var SURFACE_PATH = join(homedir(), ".paigy", "surface.json");
3110
- function writeSurface(label, code, ttlSeconds) {
3111
- try {
3112
- mkdirSync(join(homedir(), ".paigy"), { recursive: true });
3113
- writeFileSync(
3114
- SURFACE_PATH,
3115
- JSON.stringify({ label, code, expiresAt: Date.now() + ttlSeconds * 1e3 }),
3116
- { mode: 384 }
3117
- );
3118
- } catch {
3119
- }
3120
- }
3121
- function clearSurface() {
3122
- try {
3123
- rmSync(SURFACE_PATH, { force: true });
3124
- } catch {
3125
- }
3126
- }
3127
- function readSurface(now = Date.now) {
3128
- try {
3129
- const { label, code, expiresAt } = JSON.parse(readFileSync(SURFACE_PATH, "utf8"));
3130
- return label && code && typeof expiresAt === "number" && expiresAt > now() ? { label, code } : null;
3131
- } catch {
3132
- return null;
3133
- }
3134
- }
3135
- function openBrowser(url) {
3136
- const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
3137
- const args = platform() === "win32" ? ["/c", "start", url] : [url];
3138
- execFile(cmd, args, () => {
3139
- });
3140
- }
3141
3180
  function saveToken(token) {
3142
3181
  mkdirSync(join(homedir(), ".paigy"), { recursive: true });
3143
3182
  writeFileSync(TOKEN_PATH, JSON.stringify(token, null, 2) + "\n", { mode: 384 });
@@ -3165,11 +3204,11 @@ async function revokeToken(token) {
3165
3204
  });
3166
3205
  return res.ok;
3167
3206
  }
3168
- async function requestCode(agent = AGENT_NAME, e2ee) {
3207
+ async function requestCode(agent2 = AGENT_NAME, e2ee) {
3169
3208
  const res = await reach(`${BACKEND_URL}/api/device/code`, {
3170
3209
  method: "POST",
3171
3210
  headers: { "content-type": "application/json" },
3172
- body: JSON.stringify(e2ee ? { agent, proto: e2ee.proto, commitment: e2ee.commitment } : { agent })
3211
+ body: JSON.stringify(e2ee ? { agent: agent2, proto: e2ee.proto, commitment: e2ee.commitment } : { agent: agent2 })
3173
3212
  });
3174
3213
  if (!res.ok) throw new Error(`/api/device/code failed: ${res.status} ${await res.text()}`);
3175
3214
  return await res.json();
@@ -3319,90 +3358,201 @@ function openSealedAnswer(sealed, keypair) {
3319
3358
  }
3320
3359
  return answer;
3321
3360
  }
3322
- function withPaigyMcpJson(existing, agent) {
3323
- let config = {};
3324
- if (existing) {
3325
- try {
3326
- config = JSON.parse(existing);
3327
- } catch {
3328
- return null;
3329
- }
3361
+ var UnpairedError = class extends Error {
3362
+ constructor() {
3363
+ super("Not paired with Paigy \u2014 no valid token (the API answered 401). Run the pairing flow, then retry.");
3364
+ this.name = "UnpairedError";
3365
+ }
3366
+ };
3367
+ function ensureAuthed(res) {
3368
+ if (res.status === 401) throw new UnpairedError();
3369
+ return res;
3370
+ }
3371
+ async function sealForE2ee(req, token, deps = {}) {
3372
+ const keyFile = (deps.readKeyFile ?? readKeyFile)();
3373
+ if (!keyFile?.e2ee) return req;
3374
+ req = deriveAsk(req);
3375
+ if (!keyFile.uikPub) {
3376
+ throw new Error("E2EE pairing is missing its pinned identity key (key.json.uikPub) \u2014 re-pair; refusing to send plaintext.");
3377
+ }
3378
+ const roster = await (deps.fetchRoster ?? fetchRoster)(token);
3379
+ if (!roster) {
3380
+ throw new Error("E2EE pairing has no device roster to seal to \u2014 refusing to send plaintext. Approve a device on your phone, then retry.");
3381
+ }
3382
+ const recipients = verifiedRecipients(roster, keyFile.uikPub);
3383
+ if (recipients.length === 0) {
3384
+ throw new Error("No roster device verified against the pinned identity key \u2014 refusing to send plaintext (a server-injected device is dropped).");
3330
3385
  }
3331
- config.mcpServers = {
3332
- ...config.mcpServers ?? {},
3333
- paigy: { command: "npx", args: ["-y", "@paigy/mcp@latest"], env: { PAIGY_AGENT: agent } }
3386
+ const sealedContext = {
3387
+ ...req.context,
3388
+ ...req.repo !== void 0 ? { repo: req.repo } : {},
3389
+ ...req.branch !== void 0 ? { branch: req.branch } : {}
3334
3390
  };
3335
- return JSON.stringify(config, null, 2);
3336
- }
3337
- function withPaigyMcpToml(existing, agent) {
3338
- if (existing.includes("[mcp_servers.paigy]")) return null;
3339
- const entry = [
3340
- "",
3341
- "[mcp_servers.paigy]",
3342
- 'command = "npx"',
3343
- 'args = ["-y", "@paigy/mcp@latest"]',
3344
- `env = { PAIGY_AGENT = "${agent}" }`,
3345
- ""
3346
- ].join("\n");
3347
- return existing + entry;
3348
- }
3349
- function autoConfigureClients(skip = AGENT_NAME) {
3350
- const home = homedir();
3351
- const registered = [];
3352
- if (skip !== "antigravity") {
3353
- const antigravityConfigs = [
3354
- join(home, ".gemini", "config", "mcp_config.json"),
3355
- join(home, ".gemini", "antigravity-cli", "mcp_config.json")
3356
- ];
3357
- for (const path of antigravityConfigs) {
3358
- try {
3359
- if (!existsSync(dirname(path))) continue;
3360
- const existing = existsSync(path) ? readFileSync(path, "utf8") : null;
3361
- const updated = withPaigyMcpJson(existing, "antigravity");
3362
- if (updated === null) continue;
3363
- writeFileSync(path, updated);
3364
- registered.push(`Google Antigravity (${path})`);
3365
- } catch {
3366
- }
3391
+ const envelope = sealFields({ context: sealedContext, options: req.options, visuals: req.visuals }, recipients);
3392
+ const { context: _c, options: _o, visuals: _v, repo: _r, branch: _b, ...meta } = req;
3393
+ return { ...meta, envelope };
3394
+ }
3395
+ async function submitNotification(req) {
3396
+ const token = readToken();
3397
+ const body = await sealForE2ee(req, token);
3398
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify`, {
3399
+ method: "POST",
3400
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
3401
+ body: JSON.stringify(body)
3402
+ }));
3403
+ if (!res.ok) throw new Error(`notify failed: ${res.status} ${await res.text()}`);
3404
+ return await res.json();
3405
+ }
3406
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
3407
+ function decryptAnswer(reply, deps = {}) {
3408
+ if (!reply.sealed) return reply.answer;
3409
+ const keyFile = (deps.readKeyFile ?? readKeyFile)();
3410
+ if (!keyFile?.keypair) {
3411
+ throw new Error("received a sealed answer but this agent has no device key to open it \u2014 re-pair.");
3412
+ }
3413
+ return openSealedAnswer(reply.sealed, keyFile.keypair);
3414
+ }
3415
+ async function pollAnswer(notificationId) {
3416
+ const token = readToken();
3417
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/await?notificationId=${encodeURIComponent(notificationId)}`, {
3418
+ headers: { authorization: `Bearer ${token}` }
3419
+ }));
3420
+ if (!res.ok) throw new Error(`await failed: ${res.status} ${await res.text()}`);
3421
+ const item = await res.json();
3422
+ return item.type === "idle" ? item : decryptItem(item);
3423
+ }
3424
+ async function awaitReply(notificationId, opts = {}) {
3425
+ const intervalMs = opts.intervalMs ?? 5e3;
3426
+ const windowMs = opts.windowMs ?? 3e5;
3427
+ const doSleep = opts.sleep ?? sleep2;
3428
+ const now = opts.now ?? Date.now;
3429
+ const start = now();
3430
+ while (true) {
3431
+ const item = await pollAnswer(notificationId);
3432
+ if (item.type !== "idle") return item;
3433
+ if (now() - start >= windowMs) return { type: "idle" };
3434
+ await doSleep(intervalMs);
3435
+ }
3436
+ }
3437
+ function decryptItem(item) {
3438
+ if (item.type !== "reply") return item;
3439
+ const enriched = { ...item, intents: landIntents(item.intents) };
3440
+ if (!enriched.sealed) return enriched;
3441
+ const answer = decryptAnswer(enriched);
3442
+ const { sealed: _s, ...rest } = enriched;
3443
+ return { ...rest, answer };
3444
+ }
3445
+ function landIntents(intents) {
3446
+ if (!intents?.length) return intents;
3447
+ return intents.map((i) => {
3448
+ if (i.kind !== "defer") return i;
3449
+ const due = parseDue(i.detail);
3450
+ return due !== null ? { ...i, dueInSeconds: due } : i;
3451
+ });
3452
+ }
3453
+ async function getThread(threadId) {
3454
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/thread/${encodeURIComponent(threadId)}`, {
3455
+ headers: { authorization: `Bearer ${readToken()}` }
3456
+ }));
3457
+ if (!res.ok) throw new Error(`get_thread failed: ${res.status} ${await res.text()}`);
3458
+ return await res.json();
3459
+ }
3460
+ async function checkReplies() {
3461
+ const token = readToken();
3462
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/pending`, {
3463
+ headers: { authorization: `Bearer ${token}` }
3464
+ }));
3465
+ if (!res.ok) throw new Error(`check_replies failed: ${res.status} ${await res.text()}`);
3466
+ const pending = await res.json();
3467
+ return {
3468
+ ...pending,
3469
+ replies: pending.replies.map((raw) => {
3470
+ const r = { ...raw, intents: landIntents(raw.intents) };
3471
+ if (!r.sealed) return r;
3472
+ const { sealed: _s, ...rest } = r;
3473
+ return { ...rest, answer: decryptAnswer(r) };
3474
+ })
3475
+ };
3476
+ }
3477
+ async function setTaskState(notificationId, state) {
3478
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify/${notificationId}/state`, {
3479
+ method: "PATCH",
3480
+ headers: { "content-type": "application/json", authorization: `Bearer ${readToken()}` },
3481
+ body: JSON.stringify({ state })
3482
+ }));
3483
+ if (!res.ok) throw new Error(`set_task_state failed: ${res.status} ${await res.text()}`);
3484
+ return await res.json();
3485
+ }
3486
+ async function registerDelivery(mode) {
3487
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/delivery`, {
3488
+ method: "POST",
3489
+ headers: { "content-type": "application/json", authorization: `Bearer ${readToken()}` },
3490
+ body: JSON.stringify({ mode })
3491
+ }));
3492
+ if (!res.ok) throw new Error(`register_delivery failed: ${res.status} ${await res.text()}`);
3493
+ return await res.json();
3494
+ }
3495
+ async function scheduleCallback(req) {
3496
+ const token = readToken();
3497
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/callback`, {
3498
+ method: "POST",
3499
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
3500
+ body: JSON.stringify(req)
3501
+ }));
3502
+ if (!res.ok) throw new Error(`schedule_callback failed: ${res.status} ${await res.text()}`);
3503
+ return await res.json();
3504
+ }
3505
+ var TITLE_MAX = 90;
3506
+ var CHUNKS_MAX = 8;
3507
+ var CHUNK_MAX = 300;
3508
+ var ASK_MAX = 600;
3509
+ var OPTION_MAX = 80;
3510
+ var NEEDS_MAX = 6;
3511
+ var UNSPEAKABLE = /```|\n/;
3512
+ function lintNotify(req) {
3513
+ const problems = [];
3514
+ if (req.ask !== void 0) {
3515
+ if (req.ask.length > ASK_MAX)
3516
+ problems.push(`ask is ${req.ask.length} chars \u2014 state the need and why it matters now in \u2264${ASK_MAX}; move detail into a smaller follow-up`);
3517
+ if (UNSPEAKABLE.test(req.ask))
3518
+ problems.push("ask contains code fences or newlines \u2014 write it as plain prose (it may be read aloud on a call)");
3519
+ for (const n of req.needs ?? []) {
3520
+ if (n.length > OPTION_MAX) problems.push(`need "${n.slice(0, 40)}\u2026" is too long \u2014 each need is a short phrase (\u2264${OPTION_MAX} chars)`);
3367
3521
  }
3522
+ if ((req.needs?.length ?? 0) > NEEDS_MAX)
3523
+ problems.push(`${req.needs?.length} needs \u2014 cap at ${NEEDS_MAX}; a call can't cover more in one conversation, split the rest into a second ask`);
3524
+ return problems;
3368
3525
  }
3369
- if (skip !== "codex") {
3370
- const codexConfig = join(home, ".codex", "config.toml");
3371
- try {
3372
- if (existsSync(dirname(codexConfig))) {
3373
- const existing = existsSync(codexConfig) ? readFileSync(codexConfig, "utf8") : "";
3374
- const updated = withPaigyMcpToml(existing, "codex");
3375
- if (updated !== null) {
3376
- writeFileSync(codexConfig, updated);
3377
- registered.push(`OpenAI Codex (${codexConfig})`);
3378
- }
3379
- }
3380
- } catch {
3526
+ const spoken = req.urgency === "call" || req.urgency === "banner";
3527
+ if (req.context) {
3528
+ if (req.context.title.length > TITLE_MAX)
3529
+ problems.push(`title is ${req.context.title.length} chars \u2014 shorten to \u2264${TITLE_MAX} (it's what shows on the banner / gets spoken on a ring)`);
3530
+ if (UNSPEAKABLE.test(req.context.title))
3531
+ problems.push("title contains code fences or newlines \u2014 one plain-prose line");
3532
+ if (req.context.description.length > CHUNKS_MAX)
3533
+ problems.push(`${req.context.description.length} description chunks \u2014 cap at ${CHUNKS_MAX}; merge or drop the rest`);
3534
+ for (const [i, chunk] of req.context.description.entries()) {
3535
+ if (chunk.length > CHUNK_MAX)
3536
+ problems.push(`description[${i}] is ${chunk.length} chars \u2014 split it into standalone points of \u2264${CHUNK_MAX}`);
3381
3537
  }
3538
+ if (spoken && UNSPEAKABLE.test(req.context.description.join(" ")))
3539
+ problems.push("urgency is 'call'/'banner' but the description has code fences/newlines-in-chunk \u2014 rewrite in spoken register (it will be read aloud)");
3382
3540
  }
3383
- return registered;
3384
- }
3385
- function claudeInstallHint(skip = AGENT_NAME) {
3386
- if (skip.startsWith("claude")) return null;
3387
- if (!existsSync(join(homedir(), ".claude"))) return null;
3388
- return "Claude Code detected \u2014 to add Paigy there, run /plugin marketplace add paigy-ai/claude then /plugin install paigy (pairing carries over; no need to pair again).";
3541
+ for (const o of req.options ?? []) {
3542
+ if (o.label.length > OPTION_MAX)
3543
+ problems.push(`option label "${o.label.slice(0, 40)}\u2026" is ${o.label.length} chars \u2014 labels must read at a glance (\u2264${OPTION_MAX}); move detail into the description`);
3544
+ }
3545
+ return problems;
3389
3546
  }
3390
3547
 
3391
3548
  export {
3549
+ BACKEND_URL,
3550
+ reach,
3392
3551
  NotifyRequestSchema,
3393
- deriveAsk,
3394
3552
  SetTaskStateSchema,
3395
3553
  ScheduleCallbackSchema,
3396
- WAKE_EVENT,
3397
- wakeChannel,
3398
- BACKEND_URL,
3399
- reach,
3400
3554
  AGENT_NAME,
3401
3555
  TOKEN_PATH,
3402
- writeSurface,
3403
- clearSurface,
3404
- readSurface,
3405
- openBrowser,
3406
3556
  saveToken,
3407
3557
  sleep,
3408
3558
  readToken,
@@ -3417,10 +3567,13 @@ export {
3417
3567
  finalizeE2ee,
3418
3568
  fetchCredential,
3419
3569
  pairStep,
3420
- fetchRoster,
3421
- verifiedRecipients,
3422
- sealFields,
3423
- openSealedAnswer,
3424
- autoConfigureClients,
3425
- claudeInstallHint
3570
+ UnpairedError,
3571
+ submitNotification,
3572
+ awaitReply,
3573
+ getThread,
3574
+ checkReplies,
3575
+ setTaskState,
3576
+ registerDelivery,
3577
+ scheduleCallback,
3578
+ lintNotify
3426
3579
  };