@paigy/mcp 0.12.1 → 0.13.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/dist/index.js CHANGED
@@ -1,25 +1,31 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
3
  import {
3
- NotifyRequestSchema,
4
- ScheduleCallbackSchema,
5
- SetTaskStateSchema,
6
4
  awaitReply,
7
5
  checkReplies,
8
6
  scheduleCallback,
9
7
  setTaskState,
10
8
  submitNotification
11
- } from "./chunk-ACPCCDHI.js";
9
+ } from "./chunk-KNCXKXUG.js";
12
10
  import {
11
+ NotifyRequestSchema,
12
+ ScheduleCallbackSchema,
13
+ SetTaskStateSchema,
14
+ deleteKeyFile,
13
15
  deleteToken,
16
+ fetchCredential,
17
+ finalizeE2ee,
14
18
  openBrowser,
15
- pollToken,
19
+ pairStep,
20
+ readKeyFile,
16
21
  readToken,
17
22
  requestCode,
18
23
  revokeToken,
24
+ saveKeyFile,
19
25
  saveToken,
20
- sleep
21
- } from "./chunk-AILC3H3U.js";
22
- import "./chunk-GU7C5H6L.js";
26
+ sleep,
27
+ startE2ee
28
+ } from "./chunk-O6W2T6D2.js";
23
29
 
24
30
  // src/index.ts
25
31
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -29,6 +35,52 @@ import {
29
35
  ListToolsRequestSchema
30
36
  } from "@modelcontextprotocol/sdk/types.js";
31
37
  import { execSync } from "child_process";
38
+
39
+ // src/lint.ts
40
+ var TITLE_MAX = 90;
41
+ var CHUNKS_MAX = 8;
42
+ var CHUNK_MAX = 300;
43
+ var ASK_MAX = 600;
44
+ var OPTION_MAX = 80;
45
+ var NEEDS_MAX = 6;
46
+ var UNSPEAKABLE = /```|\n/;
47
+ function lintNotify(req) {
48
+ const problems = [];
49
+ if (req.ask !== void 0) {
50
+ if (req.ask.length > ASK_MAX)
51
+ 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`);
52
+ if (UNSPEAKABLE.test(req.ask))
53
+ problems.push("ask contains code fences or newlines \u2014 write it as plain prose (it may be read aloud on a call)");
54
+ for (const n of req.needs ?? []) {
55
+ 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)`);
56
+ }
57
+ if ((req.needs?.length ?? 0) > NEEDS_MAX)
58
+ 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`);
59
+ return problems;
60
+ }
61
+ const spoken = req.urgency === "call" || req.urgency === "banner";
62
+ if (req.context) {
63
+ if (req.context.title.length > TITLE_MAX)
64
+ 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)`);
65
+ if (UNSPEAKABLE.test(req.context.title))
66
+ problems.push("title contains code fences or newlines \u2014 one plain-prose line");
67
+ if (req.context.description.length > CHUNKS_MAX)
68
+ problems.push(`${req.context.description.length} description chunks \u2014 cap at ${CHUNKS_MAX}; merge or drop the rest`);
69
+ for (const [i, chunk] of req.context.description.entries()) {
70
+ if (chunk.length > CHUNK_MAX)
71
+ problems.push(`description[${i}] is ${chunk.length} chars \u2014 split it into standalone points of \u2264${CHUNK_MAX}`);
72
+ }
73
+ if (spoken && UNSPEAKABLE.test(req.context.description.join(" ")))
74
+ 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)");
75
+ }
76
+ for (const o of req.options ?? []) {
77
+ if (o.label.length > OPTION_MAX)
78
+ 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`);
79
+ }
80
+ return problems;
81
+ }
82
+
83
+ // src/index.ts
32
84
  import { z } from "zod";
33
85
  import qrcode from "qrcode-generator";
34
86
 
@@ -84,11 +136,41 @@ function detectGit() {
84
136
  branch: branch && branch !== "HEAD" ? branch : void 0
85
137
  };
86
138
  }
139
+ function pairedResult(token, sas, note, e2ee) {
140
+ const base = {
141
+ status: "paired",
142
+ nickname: token.nickname,
143
+ agent: token.agent,
144
+ device: token.device
145
+ };
146
+ if (sas) {
147
+ base.e2ee = !!e2ee;
148
+ base.sas = sas;
149
+ base.verify_message = `This pairing is end-to-end encrypted. Ask the user to verify this code matches the one on their phone: ${sas}` + (e2ee ? "" : " (identity key not yet confirmed on the phone).");
150
+ }
151
+ if (note) base.note = note;
152
+ return { content: [{ type: "text", text: JSON.stringify(base) }] };
153
+ }
154
+ function awaitingConfirmResult(sas, device_code) {
155
+ return {
156
+ content: [{
157
+ type: "text",
158
+ text: JSON.stringify({
159
+ status: "awaiting_confirmation",
160
+ e2ee: true,
161
+ sas,
162
+ 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."
165
+ })
166
+ }]
167
+ };
168
+ }
87
169
  var server = new Server(
88
170
  { name: "paigy", version: "0.0.0" },
89
171
  {
90
172
  capabilities: { tools: {} },
91
- instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without notify_user + await_reply. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see notify_user's own description for how to respond. When urgency is 'call', remember the title + description are spoken aloud \u2014 write them short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. Escalate silence, don't just wait on it: if you notified at a lower urgency (inbox/push/banner) for something that's genuinely blocking real progress, call await_reply up to twice (~5 min each, ~10 min total) \u2014 if it's still idle after that AND the item is genuinely blocking, send a fresh notify_user on the SAME threadId at urgency:'call'. Skip this for anything that isn't truly blocking; a normal question can just sit in the inbox."
173
+ instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without notify_user + await_reply. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see notify_user's own description for how to respond. When urgency is 'call', remember the title + description are spoken aloud \u2014 write them short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. Escalate silence, don't just wait on it: if you notified at a lower urgency (inbox/push/banner) for something that's genuinely blocking real progress, call await_reply up to twice (~5 min each, ~10 min total) \u2014 if it's still idle after that AND the item is genuinely blocking, send a fresh notify_user on the SAME threadId at urgency:'call'. Skip this for anything that isn't truly blocking; a normal question can just sit in the inbox. NEVER go quietly idle while something might still be pending for you: whenever you end a turn with any Paigy notification unanswered (or any chance the user replied through the app while you worked), schedule your own ~2-minute wake-up (harness ScheduleWakeup or equivalent) and call check_replies when it fires; if still nothing, re-schedule and keep looping until resolved or the user says stop. For legibility, always use this exact wording \u2014 reason: 'Paigy idle check \u2014 waiting on <thing>', wake-up prompt: 'Paigy idle check: call check_replies and engage with anything unacknowledged; if idle, re-schedule (~2min).' \u2014 so the user can recognize every idle check at a glance. This self-polling in your own live session (full context intact) is the PRIMARY mechanism; the plugin's Stop hooks are only the dead-session safety net."
92
174
  }
93
175
  );
94
176
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -105,17 +187,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
105
187
  },
106
188
  {
107
189
  name: "notify_user",
108
- 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. 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.",
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.",
109
191
  inputSchema: json(NotifyRequestSchema)
110
192
  },
111
193
  {
112
194
  name: "await_reply",
113
- description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from notify_user). This is how you wait for your answer in-context. Polls ~5 min; returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (timed out this window, no answer yet). On idle, if this is genuinely still blocking you and you have nothing else useful to do meanwhile, just call await_reply again immediately \u2014 keep looping. This is how you actually deliver on the point of calling: the user steps away for a while and comes back to find you'd already continued the moment they answered, not idle waiting to be checked on. Don't give up after one window. Only stop looping to do other work (and check back later), or after an unreasonably long stretch (tens of minutes to hours) worth telling the user about instead. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications, so concurrent notify_user calls don't cross. A CALL answer can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a blocking question'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (notify_user with the reply's threadId) when the task is done or you hit a blocker \u2014 urgency:'call' for a blocker, 'banner'/'push'/'inbox' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing).",
195
+ description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from notify_user). This is how you wait for your answer in-context. Polls ~5 min; returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (timed out this window, no answer yet). On idle, if this is genuinely still blocking you and you have nothing else useful to do meanwhile, just call await_reply again immediately \u2014 keep looping. This is how you actually deliver on the point of calling: the user steps away for a while and comes back to find you'd already continued the moment they answered, not idle waiting to be checked on. Don't give up after one window. Only stop looping to do other work (and check back later), or after an unreasonably long stretch (tens of minutes to hours) worth telling the user about instead. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications, so concurrent notify_user calls don't cross. A CALL answer can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a blocking question'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (notify_user with the reply's threadId) when the task is done or you hit a blocker \u2014 urgency:'call' for a blocker, 'banner'/'push'/'inbox' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry `intents` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' (\"call me after lunch\") \u2192 register it NOW with schedule_callback \u2014 when the intent carries `dueInSeconds` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' (\"you pick\") \u2192 make the call yourself and tell them what you chose; 'channel' (\"text me next time\") \u2192 honor it on your next contact (lower urgency). `transcript` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions (\"yes, IF tests pass\") before acting. If your ask declared `points`, the reply carries `covered` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (notify_user on the same threadId) or proceed knowingly partial; never treat a partial answer as complete.",
114
196
  inputSchema: json(AwaitReplySchema)
115
197
  },
116
198
  {
117
199
  name: "check_replies",
118
- description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, threadId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a notify_user call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with notify_user on its threadId. Also returns `stalled`: work (either direction) you reported in_progress via set_task_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled.",
200
+ description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, threadId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a notify_user call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with notify_user on its threadId. Also returns `stalled`: work (either direction) you reported in_progress via set_task_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. Replies may carry `intents`/`transcript`/`covered` (call-mapped answers) \u2014 handle intents exactly as await_reply's description says (defer \u2192 schedule_callback now; delegate \u2192 decide and say so; channel \u2192 honor next contact), and treat a `covered` list missing one of your declared points as that part still unanswered.",
119
201
  inputSchema: json(z.object({}))
120
202
  },
121
203
  {
@@ -135,7 +217,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
135
217
  case "pair": {
136
218
  const { device_code } = PairSchema.parse(request.params.arguments ?? {});
137
219
  if (!device_code) {
138
- const code = await requestCode();
220
+ const { keyFile, offer } = startE2ee();
221
+ const code = await requestCode(void 0, offer);
222
+ saveKeyFile({ ...keyFile, userCode: code.user_code });
139
223
  openBrowser(code.verification_uri_complete);
140
224
  const qr = qrcode(0, "M");
141
225
  qr.addData(code.verification_uri_complete);
@@ -155,18 +239,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
155
239
  }]
156
240
  };
157
241
  }
242
+ let kf = readKeyFile();
158
243
  const start = Date.now();
159
244
  const capMs = 9e4;
160
245
  while (Date.now() - start < capMs) {
161
- const token = await pollToken(device_code);
162
- if (token) {
163
- saveToken(token);
164
- return {
165
- content: [{
166
- type: "text",
167
- text: JSON.stringify({ status: "paired", nickname: token.nickname, agent: token.agent, device: token.device })
168
- }]
169
- };
246
+ const step = await pairStep(device_code, kf);
247
+ if (step.kind === "e2ee_aborted") {
248
+ deleteKeyFile();
249
+ kf = null;
250
+ await sleep(2e3);
251
+ continue;
252
+ }
253
+ if (step.kind === "awaiting_confirm") {
254
+ return awaitingConfirmResult(step.sas, device_code);
255
+ }
256
+ if (step.kind === "paired") {
257
+ saveToken(step.token);
258
+ if (!step.sas || !kf) {
259
+ deleteKeyFile();
260
+ return pairedResult(step.token);
261
+ }
262
+ const finalDeadline = Math.min(Date.now() + 1e4, start + capMs);
263
+ let e2ee = false;
264
+ while (Date.now() < finalDeadline) {
265
+ const cred = kf.userCode ? await fetchCredential(kf.userCode) : null;
266
+ if (cred && step.uikPub) {
267
+ e2ee = finalizeE2ee(kf, cred, step.uikPub);
268
+ break;
269
+ }
270
+ await sleep(2e3);
271
+ }
272
+ if (!e2ee) deleteKeyFile();
273
+ return pairedResult(step.token, step.sas, void 0, e2ee);
170
274
  }
171
275
  await sleep(2e3);
172
276
  }
@@ -192,6 +296,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
192
296
  }
193
297
  }
194
298
  const removed = deleteToken();
299
+ deleteKeyFile();
195
300
  return {
196
301
  content: [{
197
302
  type: "text",
@@ -206,6 +311,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
206
311
  }
207
312
  case "notify_user": {
208
313
  const parsed = NotifyRequestSchema.parse(request.params.arguments);
314
+ const problems = lintNotify(parsed);
315
+ if (problems.length) {
316
+ return {
317
+ isError: true,
318
+ content: [{ type: "text", text: JSON.stringify({ error: "fix_and_retry", problems }) }]
319
+ };
320
+ }
209
321
  const git = detectGit();
210
322
  const enriched = {
211
323
  ...parsed,
package/dist/listen.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
3
  import {
3
- WAKE_EVENT,
4
4
  checkReplies,
5
- registerDelivery,
5
+ registerDelivery
6
+ } from "./chunk-KNCXKXUG.js";
7
+ import {
8
+ WAKE_EVENT,
6
9
  wakeChannel
7
- } from "./chunk-ACPCCDHI.js";
8
- import "./chunk-GU7C5H6L.js";
10
+ } from "./chunk-O6W2T6D2.js";
9
11
 
10
12
  // src/listen.ts
11
13
  import { createClient } from "@supabase/supabase-js";
package/dist/onboard.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
3
  import {
3
4
  AGENT_NAME,
4
5
  TOKEN_PATH,
@@ -7,8 +8,7 @@ import {
7
8
  requestCode,
8
9
  saveToken,
9
10
  sleep
10
- } from "./chunk-AILC3H3U.js";
11
- import "./chunk-GU7C5H6L.js";
11
+ } from "./chunk-O6W2T6D2.js";
12
12
 
13
13
  // src/onboard.ts
14
14
  async function main() {
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
3
  import {
4
+ BACKEND_URL,
3
5
  readToken
4
- } from "./chunk-AILC3H3U.js";
5
- import {
6
- BACKEND_URL
7
- } from "./chunk-GU7C5H6L.js";
6
+ } from "./chunk-O6W2T6D2.js";
8
7
 
9
8
  // src/statusline.ts
10
9
  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.12.1",
3
+ "version": "0.13.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",
@@ -35,11 +35,12 @@
35
35
  "tsup": "^8.3.5",
36
36
  "typescript": "^5.7.2",
37
37
  "vitest": "^2.1.8",
38
+ "@paigy/crypto": "0.0.0",
38
39
  "@paigy/schema": "0.0.0"
39
40
  },
40
41
  "scripts": {
41
- "build": "tsup src/index.ts src/onboard.ts src/listen.ts src/statusline.ts --format esm --clean",
42
- "dev": "tsup src/index.ts src/onboard.ts src/listen.ts src/statusline.ts --format esm --watch",
42
+ "build": "tsup",
43
+ "dev": "tsup --watch",
43
44
  "typecheck": "tsc --noEmit",
44
45
  "test": "vitest run"
45
46
  }