@paigy/mcp 0.17.1 → 0.19.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
@@ -92,8 +92,9 @@ Then pair: `npx -p @paigy/mcp@latest paigy-mcp-onboard`.
92
92
 
93
93
  ## Tools
94
94
 
95
- - **`pair`** — pair this agent with the user's Paigy account (one-time). No args to start (opens the approval link); pass the returned `device_code` to finish.
95
+ - **`pair`** — pair this agent with the user's Paigy account (one-time). No args to start: returns the code to show the user **and begins polling for approval in the background**; pass the returned `device_code` to collect the result (it returns the moment the user approves). On success it prompts you to allowlist Paigy's notify/await tools so they run without an approval prompt each time.
96
96
  - **`unpair`** — log this agent out of the user's Paigy account; revokes the token server-side and deletes the local one.
97
+ - **`enable_tools`** — after pairing (and only with the user's consent), allowlist Paigy's notify/await tools so they run without an approval prompt each time. Writes Claude Code's `permissions.allow` — `scope:'user'` (default, every project) or `scope:'project'` (this repo). Merges, never clobbers; leaves `pair`/`unpair` human-approved.
97
98
  - **`notify_user`** — notify the user (context: title + description chunks; a required `select` answer shape — `one`/`many`/`rank` take `options`, `confirm`/`text` don't; plus `visuals`, `urgency`, and `parentId` for a clarification). Options carry no ids — the backend assigns them by position ("1", "2", …) and answers reference those; each option can carry a sandboxed `html` or `image` preview for visual "pick one" decisions. Returns `{ notificationId, threadId }`. If a reply comes back as `{kind:'clarify', chunks:[...]}`, respond via notify_user with the SAME threadId and an expanded description.
98
99
  - **`await_reply`** — wait for the user's reply to a specific notification (pass its notificationId). Scoped: will not return replies meant for other notifications. Returns `reply` / `remind` / `idle`.
99
100
  - **`check_replies`** — catch-up sweep: returns replies you haven't consumed yet (now marked seen) plus still-pending notifications, new user-initiated requests, and owed callbacks.
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  AGENT_NAME
3
- } from "./chunk-RMTTO6BI.js";
3
+ } from "./chunk-VWQQ3VJF.js";
4
4
 
5
5
  // src/clients.ts
6
6
  import { execFile } from "child_process";
7
- import { existsSync, readFileSync, writeFileSync } from "fs";
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
8
  import { homedir, platform } from "os";
9
9
  import { join, dirname } from "path";
10
10
  function openBrowser(url) {
@@ -81,9 +81,73 @@ function claudeInstallHint(skip = AGENT_NAME) {
81
81
  if (!existsSync(join(homedir(), ".claude"))) return null;
82
82
  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).";
83
83
  }
84
+ var PAIGY_TOOL_IDS = [
85
+ "mcp__paigy__notify_user",
86
+ "mcp__paigy__notify",
87
+ "mcp__paigy__await_reply",
88
+ "mcp__paigy__check_replies",
89
+ "mcp__paigy__set_task_state",
90
+ "mcp__paigy__schedule_callback",
91
+ "mcp__paigy__get_thread"
92
+ ];
93
+ function withPaigyAllowlist(existing, tools) {
94
+ let config = {};
95
+ if (existing) {
96
+ try {
97
+ config = JSON.parse(existing);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ const permissions = config.permissions && typeof config.permissions === "object" ? config.permissions : {};
103
+ const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
104
+ for (const t of tools) if (!allow.includes(t)) allow.push(t);
105
+ config.permissions = { ...permissions, allow };
106
+ return JSON.stringify(config, null, 2);
107
+ }
108
+ function enablePaigyTools(scope = "user", cwd = process.cwd()) {
109
+ if (scope === "user" && !existsSync(join(homedir(), ".claude"))) {
110
+ return {
111
+ ok: false,
112
+ reason: `No ~/.claude on this machine \u2014 the hosting tool uses its own permission allowlist. Add these ids there by hand: ${PAIGY_TOOL_IDS.join(", ")}.`
113
+ };
114
+ }
115
+ const path = scope === "project" ? join(cwd, ".claude", "settings.json") : join(homedir(), ".claude", "settings.json");
116
+ let existing = null;
117
+ try {
118
+ existing = existsSync(path) ? readFileSync(path, "utf8") : null;
119
+ } catch {
120
+ existing = null;
121
+ }
122
+ let prevAllow = [];
123
+ if (existing) {
124
+ try {
125
+ const parsed = JSON.parse(existing);
126
+ if (Array.isArray(parsed.permissions?.allow)) prevAllow = parsed.permissions.allow;
127
+ } catch {
128
+ return { ok: false, reason: `${path} didn't parse as JSON \u2014 not overwriting it. Add the ids by hand.` };
129
+ }
130
+ }
131
+ const updated = withPaigyAllowlist(existing, PAIGY_TOOL_IDS);
132
+ if (updated === null) return { ok: false, reason: `${path} didn't parse as JSON \u2014 not overwriting it. Add the ids by hand.` };
133
+ try {
134
+ mkdirSync(dirname(path), { recursive: true });
135
+ writeFileSync(path, updated + "\n");
136
+ } catch (e) {
137
+ return { ok: false, reason: `Couldn't write ${path}: ${e.message}` };
138
+ }
139
+ return {
140
+ ok: true,
141
+ path,
142
+ added: PAIGY_TOOL_IDS.filter((t) => !prevAllow.includes(t)),
143
+ already: PAIGY_TOOL_IDS.filter((t) => prevAllow.includes(t))
144
+ };
145
+ }
84
146
 
85
147
  export {
86
148
  openBrowser,
87
149
  autoConfigureClients,
88
- claudeInstallHint
150
+ claudeInstallHint,
151
+ PAIGY_TOOL_IDS,
152
+ enablePaigyTools
89
153
  };
@@ -329,8 +329,9 @@ var InboxItemSchema = z.object({
329
329
  * ("call back after lunch") — shown so they can see the commitment was captured. */
330
330
  intents: z.array(IntentSchema).optional(),
331
331
  visuals: z.array(VisualSchema).optional(),
332
- agent: z.string(),
333
- nickname: z.string(),
332
+ /** The connected agent's name (the single pairing name — user-typed, or the
333
+ * agent's suggestion, or a default silly name). */
334
+ name: z.string(),
334
335
  /** The pairing's assigned voice (#462); absent = the default voice. */
335
336
  voice: VoiceKeySchema.optional(),
336
337
  repo: z.string().optional(),
@@ -438,8 +439,8 @@ var HistoryItemSchema = z.object({
438
439
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
439
440
  initiator: z.enum(["user", "agent"]),
440
441
  title: z.string(),
441
- /** The agent on the other end (nickname). */
442
- agent: z.string(),
442
+ /** The agent on the other end (its name). */
443
+ name: z.string(),
443
444
  createdAt: z.string(),
444
445
  /** When the agent fetched your request (user→agent only). */
445
446
  agentAckedAt: z.string().nullable(),
@@ -449,9 +450,12 @@ var HistoryItemSchema = z.object({
449
450
  var ConnectionSummarySchema = z.object({
450
451
  /** The connection = the agent's token id (used to address a request). */
451
452
  id: z.string(),
452
- agent: z.string(),
453
453
  device: z.string().nullable(),
454
- nickname: z.string(),
454
+ /** The agent's display name (the single pairing name). */
455
+ name: z.string(),
456
+ /** For a managed connection, the provider key (e.g. "cma") that agentOrigin maps to a
457
+ * label; null for a local connection. Sourced from the token's provider, not the name. */
458
+ provider: z.string().nullable(),
455
459
  /** The pairing's assigned voice (#462); null = the default voice. */
456
460
  voice: VoiceKeySchema.nullable(),
457
461
  createdAt: z.string().datetime(),
@@ -502,8 +506,7 @@ var DeliveryConfigSchema = z.object({
502
506
  realtime: z.object({ url: z.string(), anonKey: z.string() }).nullable()
503
507
  });
504
508
  var StatusSchema = z.object({
505
- agent: z.string(),
506
- nickname: z.string(),
509
+ name: z.string(),
507
510
  sessionMode: z.enum(["default", "all_calls", "silent"]),
508
511
  /** A phone is registered for push/ring (any push token on the account). */
509
512
  phone: z.boolean()
@@ -550,14 +553,6 @@ var WakeNudgeSchema = z.object({
550
553
  notificationId: z.string().optional(),
551
554
  threadId: z.string()
552
555
  });
553
- var DeviceAgentTokenSchema = z.object({
554
- token: z.string(),
555
- deviceId: z.string(),
556
- agentName: z.string(),
557
- /** User-chosen session label; defaults to `<agent> <device>`. */
558
- nickname: z.string(),
559
- createdAt: z.string().datetime()
560
- });
561
556
  var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
562
557
  var PairingRevealSchema = z.object({
563
558
  x25519: z.string(),
@@ -565,6 +560,12 @@ var PairingRevealSchema = z.object({
565
560
  nonce: z.string()
566
561
  });
567
562
  var DeviceCodeRequestSchema = z.object({
563
+ /** The agent's suggested name for the pairing — the human sees it pre-filled at
564
+ * approval and can override. Optional; blank → a default silly name server-side. */
565
+ suggestedName: z.string().optional(),
566
+ /** Legacy alias for suggestedName (older MCPs sent `agent`). Accepted for
567
+ * back-compat; `suggestedName` wins when both are present. ponytail: drop once no
568
+ * pre-`name` MCP is in the wild. */
568
569
  agent: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
569
570
  device: z.string().optional(),
570
571
  proto: z.string().optional(),
@@ -582,7 +583,9 @@ var DeviceCodeSchema = z.object({
582
583
  });
583
584
  var DeviceInfoSchema = z.object({
584
585
  code: z.string(),
585
- agent: z.string(),
586
+ /** The agent's suggested name (from /device/code) — shown on the approval screen,
587
+ * pre-filling the name field the human can edit. */
588
+ name: z.string(),
586
589
  device: z.string().nullable(),
587
590
  status: PairingStatusSchema,
588
591
  proto: z.string().nullable().optional(),
@@ -605,8 +608,9 @@ var DeviceTokenRequestSchema = z.object({
605
608
  });
606
609
  var DeviceTokenSchema = z.object({
607
610
  access_token: z.string(),
608
- nickname: z.string(),
609
- agent: z.string(),
611
+ /** The pairing's single name (user-typed at approval, the agent's suggestion, or
612
+ * a default silly name). */
613
+ name: z.string(),
610
614
  device: z.string().nullable(),
611
615
  phone_reveal: PairingRevealSchema.nullable().optional(),
612
616
  // present once the phone reveals
@@ -2618,8 +2618,9 @@ var InboxItemSchema = z.object({
2618
2618
  * ("call back after lunch") — shown so they can see the commitment was captured. */
2619
2619
  intents: z.array(IntentSchema).optional(),
2620
2620
  visuals: z.array(VisualSchema).optional(),
2621
- agent: z.string(),
2622
- nickname: z.string(),
2621
+ /** The connected agent's name (the single pairing name — user-typed, or the
2622
+ * agent's suggestion, or a default silly name). */
2623
+ name: z.string(),
2623
2624
  /** The pairing's assigned voice (#462); absent = the default voice. */
2624
2625
  voice: VoiceKeySchema.optional(),
2625
2626
  repo: z.string().optional(),
@@ -2727,8 +2728,8 @@ var HistoryItemSchema = z.object({
2727
2728
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
2728
2729
  initiator: z.enum(["user", "agent"]),
2729
2730
  title: z.string(),
2730
- /** The agent on the other end (nickname). */
2731
- agent: z.string(),
2731
+ /** The agent on the other end (its name). */
2732
+ name: z.string(),
2732
2733
  createdAt: z.string(),
2733
2734
  /** When the agent fetched your request (user→agent only). */
2734
2735
  agentAckedAt: z.string().nullable(),
@@ -2738,9 +2739,12 @@ var HistoryItemSchema = z.object({
2738
2739
  var ConnectionSummarySchema = z.object({
2739
2740
  /** The connection = the agent's token id (used to address a request). */
2740
2741
  id: z.string(),
2741
- agent: z.string(),
2742
2742
  device: z.string().nullable(),
2743
- nickname: z.string(),
2743
+ /** The agent's display name (the single pairing name). */
2744
+ name: z.string(),
2745
+ /** For a managed connection, the provider key (e.g. "cma") that agentOrigin maps to a
2746
+ * label; null for a local connection. Sourced from the token's provider, not the name. */
2747
+ provider: z.string().nullable(),
2744
2748
  /** The pairing's assigned voice (#462); null = the default voice. */
2745
2749
  voice: VoiceKeySchema.nullable(),
2746
2750
  createdAt: z.string().datetime(),
@@ -2789,8 +2793,7 @@ var DeliveryConfigSchema = z.object({
2789
2793
  realtime: z.object({ url: z.string(), anonKey: z.string() }).nullable()
2790
2794
  });
2791
2795
  var StatusSchema = z.object({
2792
- agent: z.string(),
2793
- nickname: z.string(),
2796
+ name: z.string(),
2794
2797
  sessionMode: z.enum(["default", "all_calls", "silent"]),
2795
2798
  /** A phone is registered for push/ring (any push token on the account). */
2796
2799
  phone: z.boolean()
@@ -2837,14 +2840,6 @@ var WakeNudgeSchema = z.object({
2837
2840
  notificationId: z.string().optional(),
2838
2841
  threadId: z.string()
2839
2842
  });
2840
- var DeviceAgentTokenSchema = z.object({
2841
- token: z.string(),
2842
- deviceId: z.string(),
2843
- agentName: z.string(),
2844
- /** User-chosen session label; defaults to `<agent> <device>`. */
2845
- nickname: z.string(),
2846
- createdAt: z.string().datetime()
2847
- });
2848
2843
  var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
2849
2844
  var PairingRevealSchema = z.object({
2850
2845
  x25519: z.string(),
@@ -2852,6 +2847,12 @@ var PairingRevealSchema = z.object({
2852
2847
  nonce: z.string()
2853
2848
  });
2854
2849
  var DeviceCodeRequestSchema = z.object({
2850
+ /** The agent's suggested name for the pairing — the human sees it pre-filled at
2851
+ * approval and can override. Optional; blank → a default silly name server-side. */
2852
+ suggestedName: z.string().optional(),
2853
+ /** Legacy alias for suggestedName (older MCPs sent `agent`). Accepted for
2854
+ * back-compat; `suggestedName` wins when both are present. ponytail: drop once no
2855
+ * pre-`name` MCP is in the wild. */
2855
2856
  agent: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
2856
2857
  device: z.string().optional(),
2857
2858
  proto: z.string().optional(),
@@ -2869,7 +2870,9 @@ var DeviceCodeSchema = z.object({
2869
2870
  });
2870
2871
  var DeviceInfoSchema = z.object({
2871
2872
  code: z.string(),
2872
- agent: z.string(),
2873
+ /** The agent's suggested name (from /device/code) — shown on the approval screen,
2874
+ * pre-filling the name field the human can edit. */
2875
+ name: z.string(),
2873
2876
  device: z.string().nullable(),
2874
2877
  status: PairingStatusSchema,
2875
2878
  proto: z.string().nullable().optional(),
@@ -2892,8 +2895,9 @@ var DeviceTokenRequestSchema = z.object({
2892
2895
  });
2893
2896
  var DeviceTokenSchema = z.object({
2894
2897
  access_token: z.string(),
2895
- nickname: z.string(),
2896
- agent: z.string(),
2898
+ /** The pairing's single name (user-typed at approval, the agent's suggestion, or
2899
+ * a default silly name). */
2900
+ name: z.string(),
2897
2901
  device: z.string().nullable(),
2898
2902
  phone_reveal: PairingRevealSchema.nullable().optional(),
2899
2903
  // present once the phone reveals
@@ -3266,11 +3270,13 @@ async function revokeToken(token) {
3266
3270
  });
3267
3271
  return res.ok;
3268
3272
  }
3269
- async function requestCode(agent2 = AGENT_NAME, e2ee) {
3273
+ async function requestCode(suggestedName = AGENT_NAME, e2ee) {
3270
3274
  const res = await reach(`${BACKEND_URL}/api/device/code`, {
3271
3275
  method: "POST",
3272
3276
  headers: { "content-type": "application/json" },
3273
- body: JSON.stringify(e2ee ? { agent: agent2, proto: e2ee.proto, commitment: e2ee.commitment } : { agent: agent2 })
3277
+ body: JSON.stringify(
3278
+ e2ee ? { suggestedName, proto: e2ee.proto, commitment: e2ee.commitment } : { suggestedName }
3279
+ )
3274
3280
  });
3275
3281
  if (!res.ok) throw new Error(`/api/device/code failed: ${res.status} ${await res.text()}`);
3276
3282
  return await res.json();
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  HandoffSchema
4
- } from "./chunk-MH6AHNUI.js";
4
+ } from "./chunk-SLWESECN.js";
5
5
  import {
6
+ PAIGY_TOOL_IDS,
6
7
  autoConfigureClients,
7
- claudeInstallHint
8
- } from "./chunk-IMIXCPTN.js";
8
+ claudeInstallHint,
9
+ enablePaigyTools
10
+ } from "./chunk-HO3OJHRA.js";
9
11
  import {
10
12
  clearSurface,
11
13
  writeSurface
@@ -36,7 +38,7 @@ import {
36
38
  sleep,
37
39
  startE2ee,
38
40
  submitNotification
39
- } from "./chunk-RMTTO6BI.js";
41
+ } from "./chunk-VWQQ3VJF.js";
40
42
 
41
43
  // src/index.ts
42
44
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -74,6 +76,81 @@ function json(s) {
74
76
  return draft2020(schema);
75
77
  }
76
78
 
79
+ // src/pairing.ts
80
+ async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
81
+ let kf = readKeyFile();
82
+ const start = Date.now();
83
+ while (Date.now() - start < capMs) {
84
+ let step;
85
+ try {
86
+ step = await pairStep(deviceCode, kf);
87
+ } catch (e) {
88
+ return { kind: "error", message: e.message };
89
+ }
90
+ if (step.kind === "e2ee_aborted") {
91
+ deleteKeyFile();
92
+ kf = null;
93
+ await sleep(pollMs);
94
+ continue;
95
+ }
96
+ if (step.kind === "awaiting_confirm") {
97
+ return { kind: "awaiting_confirm", sas: step.sas };
98
+ }
99
+ if (step.kind === "paired") {
100
+ saveToken(step.token);
101
+ if (!step.sas || !kf) {
102
+ deleteKeyFile();
103
+ return { kind: "paired", token: step.token };
104
+ }
105
+ const finalDeadline = Math.min(Date.now() + 1e4, start + capMs);
106
+ let e2ee = false;
107
+ while (Date.now() < finalDeadline) {
108
+ const cred = kf.userCode ? await fetchCredential(kf.userCode) : null;
109
+ if (cred && step.uikPub) {
110
+ e2ee = finalizeE2ee(kf, cred, step.uikPub);
111
+ break;
112
+ }
113
+ await sleep(pollMs);
114
+ }
115
+ if (!e2ee) deleteKeyFile();
116
+ return { kind: "paired", token: step.token, sas: step.sas, e2ee };
117
+ }
118
+ await sleep(pollMs);
119
+ }
120
+ return { kind: "pending" };
121
+ }
122
+ var bg = null;
123
+ function startBackgroundPair(deviceCode, budgetMs) {
124
+ const promise = resolvePairing(deviceCode, budgetMs).catch((e) => ({ kind: "error", message: e.message })).then((o) => {
125
+ if (bg?.deviceCode === deviceCode) bg.settled = o;
126
+ return o;
127
+ });
128
+ bg = { deviceCode, promise };
129
+ }
130
+ async function joinBackgroundPair(deviceCode, capMs) {
131
+ if (!bg || bg.deviceCode !== deviceCode) return null;
132
+ if (bg.settled) {
133
+ const s = bg.settled;
134
+ bg = null;
135
+ return s;
136
+ }
137
+ const TIMEOUT = /* @__PURE__ */ Symbol("timeout");
138
+ const raced = await Promise.race([bg.promise, sleep(capMs).then(() => TIMEOUT)]);
139
+ if (raced !== TIMEOUT) {
140
+ bg = null;
141
+ return raced;
142
+ }
143
+ if (bg?.settled) {
144
+ const s = bg.settled;
145
+ bg = null;
146
+ return s;
147
+ }
148
+ return { kind: "pending" };
149
+ }
150
+ function cancelBackgroundPair() {
151
+ bg = null;
152
+ }
153
+
77
154
  // src/index.ts
78
155
  var ONBOARD_MSG = "Not paired with Paigy yet \u2014 call the `pair` tool to connect this agent (it returns an approval link to show the user), then retry. Manual fallback: `npx -y -p @paigy/mcp paigy-mcp-onboard`.";
79
156
  var AwaitReplySchema = z.object({
@@ -85,6 +162,9 @@ var PairSchema = z.object({
85
162
  var GetThreadSchema = z.object({
86
163
  threadId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
87
164
  });
165
+ var EnableToolsSchema = z.object({
166
+ scope: z.enum(["user", "project"]).optional().describe("'user' (default) allowlists Paigy's tools for every project (~/.claude/settings.json); 'project' scopes them to this repo (.claude/settings.json).")
167
+ });
88
168
  var SetTaskStateToolSchema = z.object({
89
169
  notificationId: z.string(),
90
170
  state: SetTaskStateSchema.shape.state
@@ -109,8 +189,7 @@ function pairedResult(token, sas, note, e2ee) {
109
189
  clearSurface();
110
190
  const base = {
111
191
  status: "paired",
112
- nickname: token.nickname,
113
- agent: token.agent,
192
+ name: token.name,
114
193
  device: token.device
115
194
  };
116
195
  if (sas) {
@@ -129,7 +208,9 @@ function pairedResult(token, sas, note, e2ee) {
129
208
  if (hint) base.claude_hint = hint;
130
209
  } catch {
131
210
  }
132
- base.permissions_hint = "Offer the user ONCE: allowlist Paigy's tools so they run without prompting (in Claude Code, add mcp__paigy__notify_user, mcp__paigy__await_reply, mcp__paigy__check_replies, mcp__paigy__set_task_state, mcp__paigy__schedule_callback, mcp__paigy__get_thread to permissions.allow in ~/.claude/settings.json \u2014 or the project's .claude/settings.json if they prefer; other tools have their own allowlist). Merge into the existing file, never clobber it. Leave pair/unpair human-approved. If they decline, drop it.";
211
+ base.enable_tool_ids = PAIGY_TOOL_IDS;
212
+ base.enable_prompt = `REQUIRED \u2014 before you do anything else, ask the user now: "Want me to let Paigy's tools run without an approval prompt each time? I'll only allowlist the notify/await tools \u2014 pair and unpair still ask." Prompt every time you pair; don't skip it.`;
213
+ base.enable_apply = "On yes: call the `enable_tools` tool \u2014 it writes the allowlist for you (scope:'user' = every project, the default; scope:'project' = just this repo). No hand-editing. If it returns ok:false (the host isn't Claude Code), fall back to adding `enable_tool_ids` to that tool's own allowlist by hand, merging \u2014 never clobbering. pair/unpair stay out, so they remain human-approved. On no: drop it, don't ask again this session.";
133
214
  return { content: [{ type: "text", text: JSON.stringify(base) }] };
134
215
  }
135
216
  function awaitingConfirmResult(sas, device_code) {
@@ -153,6 +234,28 @@ Check it matches the code on the phone, then type it there to confirm.` },
153
234
  ]
154
235
  };
155
236
  }
237
+ function renderPairOutcome(outcome, device_code) {
238
+ switch (outcome.kind) {
239
+ case "error":
240
+ clearSurface();
241
+ throw new Error(outcome.message);
242
+ case "awaiting_confirm":
243
+ return awaitingConfirmResult(outcome.sas, device_code);
244
+ case "paired":
245
+ return pairedResult(outcome.token, outcome.sas, void 0, outcome.e2ee);
246
+ case "pending":
247
+ return {
248
+ content: [{
249
+ type: "text",
250
+ text: JSON.stringify({
251
+ status: "pending",
252
+ device_code,
253
+ message: "Still awaiting approval. Call pair again with this device_code to keep waiting."
254
+ })
255
+ }]
256
+ };
257
+ }
258
+ }
156
259
  var server = new Server(
157
260
  { name: "paigy", version: "0.0.0" },
158
261
  {
@@ -164,7 +267,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
164
267
  tools: [
165
268
  {
166
269
  name: "pair",
167
- description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before notify_user/await_reply work. It does NOT open a browser; the user enters the code in the Paigy app (or scans `qr`). Step 1: call with NO args \u2014 returns { user_code, device_code, qr, user_message }. Then END YOUR TURN immediately, replying with `user_message` (the bare code) as your ENTIRE message \u2014 do NOT chain the step-2 call in the same turn, or the code text is dropped before the user ever sees it (this is the #1 pairing bug). Step 2 (a SEPARATE later turn, after the user says they've entered it): call with that device_code to poll for approval; on { status:'pending' } call again to keep waiting; on { status:'awaiting_confirmation' } (E2EE) show the bare `user_message` verify code and end the turn again, polling on the next. The leading text block of every result already states the code plainly, so it shows even if you emit no prose.",
270
+ description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before notify_user/await_reply work. It does NOT open a browser; the user enters the code in the Paigy app (or scans `qr`). Step 1: call with NO args \u2014 returns { user_code, device_code, qr, user_message } AND starts polling for approval in the background. SHOW the user `user_message` (the bare code) so it isn't buried in prose, THEN call step 2 \u2014 you can do both in the same turn (emit the code text first, then call pair). Step 2: call with that device_code to collect the result. Because approval is already being polled in the background, this returns the moment the user approves; on { status:'pending' } just call again to keep waiting; on { status:'awaiting_confirmation' } (E2EE) show the bare `user_message` verify code and call again to finish. The leading text block of every result states the code plainly, so it shows even if you emit no prose. On { status:'paired' } ALWAYS follow the `enable_prompt` \u2014 ask the user to allowlist Paigy's tools so notify/await don't prompt each time.",
168
271
  inputSchema: json(PairSchema)
169
272
  },
170
273
  {
@@ -172,6 +275,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
172
275
  description: "Log out / unpair this agent from the user's Paigy account: revokes the token server-side (it stops working everywhere) and deletes the local ~/.paigy/token.json. Takes no arguments. After this, notify_user/await_reply won't work until the user pairs again with the pair tool.",
173
276
  inputSchema: json(z.object({}))
174
277
  },
278
+ {
279
+ name: "enable_tools",
280
+ description: "Allowlist Paigy's notify/await tools so they run WITHOUT an approval prompt each time \u2014 call this AFTER pairing, once the user has said yes to the `enable_prompt` (never without their consent). It writes the hosting tool's permission allowlist for you: `scope:'user'` (default) covers every project (~/.claude/settings.json), `scope:'project'` scopes it to this repo (.claude/settings.json). Merges into the existing file (never clobbers) and leaves pair/unpair OUT so they stay human-approved. Returns { ok, path, added, already } on success, or { ok:false, reason } when the host isn't Claude Code (add the ids to that tool's own allowlist by hand). Idempotent \u2014 re-running just reports everything already present.",
281
+ inputSchema: json(EnableToolsSchema)
282
+ },
175
283
  {
176
284
  name: "notify_user",
177
285
  description: "Notify the user via Paigy. Returns { notificationId, threadId } \u2014 pass notificationId to await_reply for the answer, threadId to notify_user to continue the conversation. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread (they leave the user's inbox; the response reports supersededCount) \u2014 right for updates to one ask, WRONG for a checklist: send parallel to-dos as separate un-threaded notifications. SIMPLEST FORM \u2014 just state what you need: { ask: \"I need to know whether to deploy the auth fix \u2014 tests are green, staging verified\", urgencyHint: 'now'|'soon'|'whenever', needs?: [\"deploy?\", \"keep the flag?\"] }. Paigy's broker derives the title, answer shape, options, and delivery channel for you \u2014 prefer this unless you specifically need to control the exact options/shape. The fully-shaped form below remains available and unchanged (the two are mutually exclusive: send `ask` OR `context`+`select`). FULLY-SHAPED FORM: provide context.title (a specific, non-empty one-line headline \u2014 this is what the user sees first, and what shows on the ring for a call) and context.description (an array of standalone, non-empty detail chunks the user can selectively ask you to expand). Set `urgency`: 'inbox' (default) drops it silently in their inbox; 'push' is a quiet passive notification (no sound); 'banner' sends a time-sensitive banner/lock-screen push (a 'paige') they tap to open \u2014 for when you need them soon-ish but not enough to ring them; 'call' rings their phone now as a voice call \u2014 only when you genuinely need them in the moment (blocked/waiting, time-sensitive). This is the premier use case for Paigy: getting UNBLOCKED so you can keep working, not just reporting that you're stuck. The user started a big task and went to do something else \u2014 the cardinal failure is going idle on one small decision and silently waiting to be checked on, so they come back to find you never actually progressed. Judge urgency by how much WORK IS BLOCKED behind this decision (a lot of dependent downstream work \u2192 call, even if it's a single question), not by how many things happen to be pending \u2014 one blocking decision outweighs five independent low-stakes ones sitting in the inbox. Set `blocking: true` whenever that's the case, independent of `urgency` \u2014 it's what lets Paigy escalate this to a real call later on its own if it goes unanswered, even if you sent it at a lower urgency. Don't set it for things you could work around, defer, or where other useful work exists meanwhile. ON A CALL, your title + description are READ ALOUD by a voice \u2014 write them to be HEARD, not read: keep it short and conversational, front-load the ask, and refer to things BY NAME, not by ID or code (say 'the pull request about the agents page', not 'PR #235'; 'the login-bug ticket', not 'ABC-1234'). Spell out only what's natural to say out loud. MATCH the answer shape to the question \u2014 `select` is required; pick the best tool for the job, not always yes/no. The user can ALWAYS add free text on top of any shape, so structuring loses nothing. Choose `select`: yes/no \u2192 select:'confirm' \u2192 {kind:'confirm', approved:boolean}. Approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve' \u2192 {kind:'confirm', approved:boolean}. Both are answerable right from the banner \u2014 no need to open the app. Pick one of several \u2192 options + select:'one' \u2192 {kind:'option', optionId}. Pick several / a subset \u2192 options + select:'many' \u2192 {kind:'multi', optionIds:[...]}. Rank or prioritize \u2192 options + select:'rank', user taps in preferred order \u2192 {kind:'ranked', optionIds:[...]}. One/many/rank/text all need the user to open the app to answer \u2014 only confirm is answerable straight from the banner. Options carry no ids \u2014 they're assigned by position ('1', '2', \u2026), and the answer's optionId(s) are those positions. For visual choices give each option a sandboxed `html` or an `image` preview (e.g. layout/UI alternatives); use `visuals` for images that set context for the whole question. select:'text' = free-form reply only (plain updates, or answers that genuinely can't be structured). If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail on those chunks \u2014 respond via notify_user with the SAME threadId and an expanded description. Pass `threadId` from a prior notify_user result or an await_reply reply to continue that conversation thread; omit it to start a new one. To follow up on a call (e.g. the user asked you to 'call me back when it's done'), reuse the threadId from that call's reply so it threads as the same conversation.",
@@ -213,7 +321,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
213
321
  },
214
322
  {
215
323
  name: "handoff",
216
- description: "Deposit your working context for a SUCCESSOR agent \u2014 what you did, what's left, links, gotchas \u2014 as one note on a thread ({ title, notes[] }). This does NOT ring the user or enter their inbox: it's context, not a question. The successor reads it back with get_thread. Pass `target` (a sibling connection's token id or agent nickname, SAME account only) to hand off DIRECTLY to that agent \u2014 the note is dispatched to it as a request it picks up. Omit `target` to leave the thread for the user to hand off to an agent themselves in the app. Pass `threadId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { threadId }.",
324
+ description: "Deposit your working context for a SUCCESSOR agent \u2014 what you did, what's left, links, gotchas \u2014 as one note on a thread ({ title, notes[] }). This does NOT ring the user or enter their inbox: it's context, not a question. The successor reads it back with get_thread. Pass `target` (a sibling connection's token id or agent name, SAME account only) to hand off DIRECTLY to that agent \u2014 the note is dispatched to it as a request it picks up. Omit `target` to leave the thread for the user to hand off to an agent themselves in the app. Pass `threadId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { threadId }.",
217
325
  inputSchema: json(HandoffSchema)
218
326
  }
219
327
  ]
@@ -235,6 +343,7 @@ async function handleTool(request) {
235
343
  const code = await requestCode(void 0, offer);
236
344
  saveKeyFile({ ...keyFile, userCode: code.user_code });
237
345
  writeSurface("pairing code", code.user_code, code.expires_in);
346
+ startBackgroundPair(code.device_code, code.expires_in * 1e3);
238
347
  const qr = qrcode(0, "M");
239
348
  qr.addData(code.verification_uri_complete);
240
349
  qr.make();
@@ -254,63 +363,15 @@ Enter it in the Paigy app: Inbox \u2192 Add a new agent.` },
254
363
  user_message: `# ${code.user_code}
255
364
 
256
365
  Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
257
- message: `STOP HERE: end your turn now with \`user_message\` as your entire reply (the bare code, nothing else) \u2014 do NOT call pair again in this same turn, or the code text is dropped before the user sees it. Do NOT open a browser. Poll for approval by calling pair with this device_code on your NEXT turn, after you've shown the code. The user may prefer scanning \`qr\` (print it verbatim in a fenced code block on request).`
366
+ message: `SHOW the user \`user_message\` now (the bare code) so it isn't buried in prose, then call pair again with this device_code to finish \u2014 I'm ALREADY polling for approval in the background, so that call returns the moment they approve (it may still be pending, just call it again). You can do both in this same turn: emit the code text first, THEN call pair. Do NOT open a browser. The user may prefer scanning \`qr\` (print it verbatim in a fenced code block on request).`
258
367
  })
259
368
  }
260
369
  ]
261
370
  };
262
371
  }
263
- let kf = readKeyFile();
264
- const start = Date.now();
265
372
  const capMs = 9e4;
266
- while (Date.now() - start < capMs) {
267
- let step;
268
- try {
269
- step = await pairStep(device_code, kf);
270
- } catch (e) {
271
- clearSurface();
272
- throw e;
273
- }
274
- if (step.kind === "e2ee_aborted") {
275
- deleteKeyFile();
276
- kf = null;
277
- await sleep(2e3);
278
- continue;
279
- }
280
- if (step.kind === "awaiting_confirm") {
281
- return awaitingConfirmResult(step.sas, device_code);
282
- }
283
- if (step.kind === "paired") {
284
- saveToken(step.token);
285
- if (!step.sas || !kf) {
286
- deleteKeyFile();
287
- return pairedResult(step.token);
288
- }
289
- const finalDeadline = Math.min(Date.now() + 1e4, start + capMs);
290
- let e2ee = false;
291
- while (Date.now() < finalDeadline) {
292
- const cred = kf.userCode ? await fetchCredential(kf.userCode) : null;
293
- if (cred && step.uikPub) {
294
- e2ee = finalizeE2ee(kf, cred, step.uikPub);
295
- break;
296
- }
297
- await sleep(2e3);
298
- }
299
- if (!e2ee) deleteKeyFile();
300
- return pairedResult(step.token, step.sas, void 0, e2ee);
301
- }
302
- await sleep(2e3);
303
- }
304
- return {
305
- content: [{
306
- type: "text",
307
- text: JSON.stringify({
308
- status: "pending",
309
- device_code,
310
- message: "Still awaiting approval after ~90s. Call pair again with this device_code to keep waiting."
311
- })
312
- }]
313
- };
373
+ const outcome = await joinBackgroundPair(device_code, capMs) ?? await resolvePairing(device_code, capMs);
374
+ return renderPairOutcome(outcome, device_code);
314
375
  }
315
376
  case "unpair": {
316
377
  const token = readToken();
@@ -324,6 +385,7 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
324
385
  }
325
386
  const removed = deleteToken();
326
387
  deleteKeyFile();
388
+ cancelBackgroundPair();
327
389
  return {
328
390
  content: [{
329
391
  type: "text",
@@ -336,6 +398,12 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
336
398
  }]
337
399
  };
338
400
  }
401
+ case "enable_tools": {
402
+ const { scope } = EnableToolsSchema.parse(request.params.arguments ?? {});
403
+ const result = enablePaigyTools(scope ?? "user");
404
+ const message = result.ok ? `Allowlisted ${result.added.length} Paigy tool(s) in ${result.path}` + (result.already.length ? ` (${result.already.length} already present).` : ".") + " They now run without an approval prompt; restart/reload the tool if it caches settings. pair/unpair stay human-approved." : result.reason;
405
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, message }) }] };
406
+ }
339
407
  // `notify` (the general routing verb) and `notify_user` (its alias) share one handler.
340
408
  case "notify":
341
409
  case "notify_user": {
package/dist/listen.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  WAKE_EVENT,
4
4
  wakeChannel
5
- } from "./chunk-MH6AHNUI.js";
5
+ } from "./chunk-SLWESECN.js";
6
6
  import {
7
7
  checkReplies,
8
8
  registerDelivery
9
- } from "./chunk-RMTTO6BI.js";
9
+ } from "./chunk-VWQQ3VJF.js";
10
10
 
11
11
  // src/listen.ts
12
12
  import { createClient } from "@supabase/supabase-js";
package/dist/onboard.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  autoConfigureClients,
4
4
  claudeInstallHint,
5
5
  openBrowser
6
- } from "./chunk-IMIXCPTN.js";
6
+ } from "./chunk-HO3OJHRA.js";
7
7
  import {
8
8
  AGENT_NAME,
9
9
  TOKEN_PATH,
@@ -11,7 +11,7 @@ import {
11
11
  requestCode,
12
12
  saveToken,
13
13
  sleep
14
- } from "./chunk-RMTTO6BI.js";
14
+ } from "./chunk-VWQQ3VJF.js";
15
15
 
16
16
  // src/onboard.ts
17
17
  async function main() {
@@ -39,9 +39,8 @@ Paigy MCP onboarding (agent: "${AGENT_NAME}")
39
39
 
40
40
  Paired! Token saved to ${TOKEN_PATH} (mode 0600).`);
41
41
  console.log("Treat this token like a password \u2014 it grants account access. Never log, echo, or commit it.");
42
- console.log(` Nickname : ${token.nickname}`);
43
- console.log(` Agent : ${token.agent}`);
44
- console.log(` Device : ${token.device ?? "(none)"}
42
+ console.log(` Name : ${token.name}`);
43
+ console.log(` Device : ${token.device ?? "(none)"}
45
44
  `);
46
45
  const registered = autoConfigureClients();
47
46
  if (registered.length) {
@@ -6,7 +6,7 @@ import {
6
6
  BACKEND_URL,
7
7
  reach,
8
8
  readToken
9
- } from "./chunk-RMTTO6BI.js";
9
+ } from "./chunk-VWQQ3VJF.js";
10
10
 
11
11
  // src/statusline.ts
12
12
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/mcp",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "Paigy MCP server — a voice inbox for your AI agents. Lets an agent notify a user and await their reply.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,13 +22,6 @@
22
22
  "url": "git+https://github.com/mauurda/paigy.git",
23
23
  "directory": "apps/mcp"
24
24
  },
25
- "scripts": {
26
- "build": "tsup",
27
- "dev": "tsup --watch",
28
- "typecheck": "tsc --noEmit",
29
- "test": "vitest run",
30
- "prepublishOnly": "pnpm --filter @paigy/schema build && pnpm --filter @paigy/crypto build && pnpm --filter @paigy/sdk build && pnpm build"
31
- },
32
25
  "dependencies": {
33
26
  "@modelcontextprotocol/sdk": "^1.0.4",
34
27
  "@supabase/supabase-js": "^2.47.10",
@@ -38,13 +31,19 @@
38
31
  "zod-to-json-schema": "^3.24.1"
39
32
  },
40
33
  "devDependencies": {
41
- "@paigy/crypto": "workspace:*",
42
- "@paigy/schema": "workspace:*",
43
- "@paigy/sdk": "workspace:*",
44
34
  "@types/node": "^22.0.0",
45
35
  "@types/qrcode-generator": "^1.0.6",
46
36
  "tsup": "^8.3.5",
47
37
  "typescript": "^5.7.2",
48
- "vitest": "^2.1.8"
38
+ "vitest": "^2.1.8",
39
+ "@paigy/crypto": "0.0.0",
40
+ "@paigy/sdk": "0.1.0",
41
+ "@paigy/schema": "0.0.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "dev": "tsup --watch",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run"
49
48
  }
50
49
  }