@paigy/mcp 0.13.0 → 0.14.1

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.
@@ -8,7 +8,7 @@ import {
8
8
  readKeyFile,
9
9
  sealFields,
10
10
  verifiedRecipients
11
- } from "./chunk-O6W2T6D2.js";
11
+ } from "./chunk-WGOGS2JC.js";
12
12
 
13
13
  // src/client.ts
14
14
  import { existsSync, readFileSync } from "fs";
@@ -2597,6 +2597,15 @@ var MissedCallSchema = z.enum([
2597
2597
  "inbox",
2598
2598
  "dismiss"
2599
2599
  ]);
2600
+ var BrokerTuningSchema = z.object({
2601
+ /** 'none' = skip the spoken ack after a mapped answer (power users find it slow). */
2602
+ ackVerbosity: z.enum(["normal", "none"]).optional(),
2603
+ /** How readily the mapper asks its one clarification: 'low' = only when truly
2604
+ * uninterpretable, 'high' = whenever not fully certain. */
2605
+ clarifyEagerness: z.enum(["low", "normal", "high"]).optional(),
2606
+ /** The user's own shorthand: when they say `say`, they mean `mean`. */
2607
+ phrasebook: z.array(z.object({ say: z.string().min(1).max(60), mean: z.string().min(1).max(120) })).max(24).optional()
2608
+ });
2600
2609
  var UserSettingsSchema = z.object({
2601
2610
  permissions: z.object({
2602
2611
  call: z.boolean(),
@@ -2622,7 +2631,10 @@ var UserSettingsSchema = z.object({
2622
2631
  * settings object without it can't silently flip the account's E2EE state.
2623
2632
  * Absent = leave unchanged on write, 'off' on read (see store.ts). The demo
2624
2633
  * account is plaintext by construction and refuses any non-'off' value. */
2625
- e2eeMode: z.enum(["off", "on"]).optional()
2634
+ e2eeMode: z.enum(["off", "on"]).optional(),
2635
+ /** Rung-2 broker tuning (#381). Optional and NOT defaulted, same stale-client
2636
+ * clobber guard as voiceMode: absent = leave unchanged on write. */
2637
+ broker: BrokerTuningSchema.optional()
2626
2638
  });
2627
2639
  var HistoryItemSchema = z.object({
2628
2640
  id: z.string(),
@@ -3091,6 +3103,32 @@ var PROTO = "paigy-pair-v2|sas=24";
3091
3103
  var SAS_BITS = 24;
3092
3104
  var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
3093
3105
  var KEY_PATH = join(homedir(), ".paigy", "key.json");
3106
+ var SURFACE_PATH = join(homedir(), ".paigy", "surface.json");
3107
+ function writeSurface(label, code, ttlSeconds) {
3108
+ try {
3109
+ mkdirSync(join(homedir(), ".paigy"), { recursive: true });
3110
+ writeFileSync(
3111
+ SURFACE_PATH,
3112
+ JSON.stringify({ label, code, expiresAt: Date.now() + ttlSeconds * 1e3 }),
3113
+ { mode: 384 }
3114
+ );
3115
+ } catch {
3116
+ }
3117
+ }
3118
+ function clearSurface() {
3119
+ try {
3120
+ rmSync(SURFACE_PATH, { force: true });
3121
+ } catch {
3122
+ }
3123
+ }
3124
+ function readSurface(now = Date.now) {
3125
+ try {
3126
+ const { label, code, expiresAt } = JSON.parse(readFileSync(SURFACE_PATH, "utf8"));
3127
+ return label && code && typeof expiresAt === "number" && expiresAt > now() ? { label, code } : null;
3128
+ } catch {
3129
+ return null;
3130
+ }
3131
+ }
3094
3132
  function openBrowser(url) {
3095
3133
  const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
3096
3134
  const args = platform() === "win32" ? ["/c", "start", url] : [url];
@@ -3290,6 +3328,9 @@ export {
3290
3328
  reach,
3291
3329
  AGENT_NAME,
3292
3330
  TOKEN_PATH,
3331
+ writeSurface,
3332
+ clearSurface,
3333
+ readSurface,
3293
3334
  openBrowser,
3294
3335
  saveToken,
3295
3336
  sleep,
package/dist/index.js CHANGED
@@ -6,16 +6,16 @@ import {
6
6
  scheduleCallback,
7
7
  setTaskState,
8
8
  submitNotification
9
- } from "./chunk-KNCXKXUG.js";
9
+ } from "./chunk-OSQ3UXY6.js";
10
10
  import {
11
11
  NotifyRequestSchema,
12
12
  ScheduleCallbackSchema,
13
13
  SetTaskStateSchema,
14
+ clearSurface,
14
15
  deleteKeyFile,
15
16
  deleteToken,
16
17
  fetchCredential,
17
18
  finalizeE2ee,
18
- openBrowser,
19
19
  pairStep,
20
20
  readKeyFile,
21
21
  readToken,
@@ -24,8 +24,9 @@ import {
24
24
  saveKeyFile,
25
25
  saveToken,
26
26
  sleep,
27
- startE2ee
28
- } from "./chunk-O6W2T6D2.js";
27
+ startE2ee,
28
+ writeSurface
29
+ } from "./chunk-WGOGS2JC.js";
29
30
 
30
31
  // src/index.ts
31
32
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -137,6 +138,7 @@ function detectGit() {
137
138
  };
138
139
  }
139
140
  function pairedResult(token, sas, note, e2ee) {
141
+ clearSurface();
140
142
  const base = {
141
143
  status: "paired",
142
144
  nickname: token.nickname,
@@ -152,6 +154,7 @@ function pairedResult(token, sas, note, e2ee) {
152
154
  return { content: [{ type: "text", text: JSON.stringify(base) }] };
153
155
  }
154
156
  function awaitingConfirmResult(sas, device_code) {
157
+ writeSurface("verify code", sas, 600);
155
158
  return {
156
159
  content: [{
157
160
  type: "text",
@@ -160,8 +163,9 @@ function awaitingConfirmResult(sas, device_code) {
160
163
  e2ee: true,
161
164
  sas,
162
165
  device_code,
163
- verify_message: `This account requires end-to-end encryption, so no access token is issued until you confirm the pairing. Ask the user to check that this code matches the one on their phone and CONFIRM it there: ${sas}. Pairing is NOT complete yet.`,
164
- message: "The user must compare the SAS above and confirm on their phone. Once they do, call pair again with this device_code to finish \u2014 the token is granted only after they confirm."
166
+ user_message: `# ${sas}`,
167
+ verify_message: `Send \`user_message\` to the user VERBATIM as your entire next message \u2014 it is pre-composed; do not rewrite or wrap it. YOUR NEXT MESSAGE TO THE USER MUST BE EXACTLY THIS CODE AND NOTHING ELSE, displayed large (a markdown heading): ${sas} \u2014 it is the verification code they must compare and type on their phone. Do not wrap it in explanation; every extra sentence buries it. This code REPLACES any earlier one (older pairing attempts are now stale).`,
168
+ message: "No token is issued until the user confirms the code match on their phone (E2EE account). After sending the bare code, call pair again with this device_code to keep waiting \u2014 the token arrives only after they confirm."
165
169
  })
166
170
  }]
167
171
  };
@@ -187,7 +191,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
187
191
  },
188
192
  {
189
193
  name: "notify_user",
190
- 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. 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.",
194
+ 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.",
191
195
  inputSchema: json(NotifyRequestSchema)
192
196
  },
193
197
  {
@@ -220,7 +224,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
220
224
  const { keyFile, offer } = startE2ee();
221
225
  const code = await requestCode(void 0, offer);
222
226
  saveKeyFile({ ...keyFile, userCode: code.user_code });
223
- openBrowser(code.verification_uri_complete);
227
+ writeSurface("pairing code", code.user_code, code.expires_in);
224
228
  const qr = qrcode(0, "M");
225
229
  qr.addData(code.verification_uri_complete);
226
230
  qr.make();
@@ -234,7 +238,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
234
238
  device_code: code.device_code,
235
239
  expires_in: code.expires_in,
236
240
  qr: qr.createASCII(1, 2),
237
- message: "Show the user verification_uri_complete and user_code (their browser should have opened). If they'd rather scan than switch to a browser themselves, print `qr` verbatim in a fenced code block (monospace, no extra indentation) \u2014 it's a scannable QR code for the same link. After they approve, call pair again with this device_code to finish."
241
+ user_message: `# ${code.user_code}
242
+
243
+ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
244
+ message: `Send \`user_message\` to the user VERBATIM as your entire next message \u2014 it is pre-composed; do not rewrite or wrap it. YOUR NEXT MESSAGE TO THE USER MUST BE EXACTLY THIS CODE displayed large (a markdown heading): ${code.user_code} \u2014 plus ONE short line: enter it in the Paigy app (Inbox \u2192 Add a new agent). No other prose. Do NOT open a browser for the user. If their phone is handy they may prefer scanning \`qr\` (print it verbatim in a fenced code block on request). Then immediately call pair again with this device_code \u2014 it polls for the approval.`
238
245
  })
239
246
  }]
240
247
  };
@@ -243,7 +250,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
243
250
  const start = Date.now();
244
251
  const capMs = 9e4;
245
252
  while (Date.now() - start < capMs) {
246
- const step = await pairStep(device_code, kf);
253
+ let step;
254
+ try {
255
+ step = await pairStep(device_code, kf);
256
+ } catch (e) {
257
+ clearSurface();
258
+ throw e;
259
+ }
247
260
  if (step.kind === "e2ee_aborted") {
248
261
  deleteKeyFile();
249
262
  kf = null;
package/dist/listen.js CHANGED
@@ -3,11 +3,11 @@ import { createRequire as __createRequire } from 'node:module'; const require =
3
3
  import {
4
4
  checkReplies,
5
5
  registerDelivery
6
- } from "./chunk-KNCXKXUG.js";
6
+ } from "./chunk-OSQ3UXY6.js";
7
7
  import {
8
8
  WAKE_EVENT,
9
9
  wakeChannel
10
- } from "./chunk-O6W2T6D2.js";
10
+ } from "./chunk-WGOGS2JC.js";
11
11
 
12
12
  // src/listen.ts
13
13
  import { createClient } from "@supabase/supabase-js";
package/dist/onboard.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  requestCode,
9
9
  saveToken,
10
10
  sleep
11
- } from "./chunk-O6W2T6D2.js";
11
+ } from "./chunk-WGOGS2JC.js";
12
12
 
13
13
  // src/onboard.ts
14
14
  async function main() {
@@ -2,8 +2,9 @@
2
2
  import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
3
  import {
4
4
  BACKEND_URL,
5
+ readSurface,
5
6
  readToken
6
- } from "./chunk-O6W2T6D2.js";
7
+ } from "./chunk-WGOGS2JC.js";
7
8
 
8
9
  // src/statusline.ts
9
10
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
@@ -18,7 +19,8 @@ var MODE_LABEL = {
18
19
  all_calls: "all calls",
19
20
  silent: "silent"
20
21
  };
21
- function render(status) {
22
+ function render(status, surface = null) {
23
+ if (surface) return `paigy \u26A0 ${surface.label}: ${surface.code}`;
22
24
  if (!status) return "paigy: unpaired";
23
25
  return `paigy: ${MODE_LABEL[status.sessionMode]} \xB7 ${status.phone ? "phone \u2713" : "no phone"}`;
24
26
  }
@@ -44,6 +46,8 @@ async function fetchStatus(token) {
44
46
  return await res.json();
45
47
  }
46
48
  async function main() {
49
+ const surface = readSurface();
50
+ if (surface) return render(null, surface);
47
51
  const token = readToken();
48
52
  if (!token) return render(null);
49
53
  const cached = readCache();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
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",