@paigy/mcp 0.15.0 → 0.16.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/listen.js CHANGED
@@ -1,19 +1,558 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
2
  import {
4
3
  checkReplies,
5
4
  registerDelivery
6
- } from "./chunk-7S6W7NEZ.js";
7
- import {
8
- WAKE_EVENT,
9
- wakeChannel
10
- } from "./chunk-W2ZS7IBA.js";
5
+ } from "./chunk-R3YGSGPX.js";
11
6
 
12
7
  // src/listen.ts
13
8
  import { createClient } from "@supabase/supabase-js";
14
9
  import { spawn } from "child_process";
15
10
  import { realpathSync } from "fs";
16
11
  import { fileURLToPath } from "url";
12
+
13
+ // ../../packages/schema/dist/index.js
14
+ import { z } from "zod";
15
+ var ContextSchema = z.object({
16
+ title: z.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
17
+ description: z.array(z.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
18
+ });
19
+ var OptionSchema = z.object({
20
+ id: z.string(),
21
+ label: z.string(),
22
+ // .describe() flows into the MCP notify_user JSON schema (zodToJsonSchema), so
23
+ // the constraints below are what an agent reads when deciding to use these.
24
+ html: z.string().max(16384).describe(
25
+ "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
26
+ ).optional(),
27
+ image: z.string().url().describe(
28
+ "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
29
+ ).optional()
30
+ });
31
+ var VisualSchema = z.object({
32
+ url: z.string().url(),
33
+ label: z.string().optional()
34
+ });
35
+ var NotifyLevelSchema = z.enum(["inbox", "push", "banner", "call"]);
36
+ var NotifyRequestSchema = z.object({
37
+ /** Plaintext message content. Present on the plaintext path (today's shape);
38
+ * ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
39
+ * superRefine at the bottom enforces exactly one of the two. */
40
+ context: ContextSchema.optional(),
41
+ options: z.array(OptionSchema.omit({ id: true })).min(1).optional().describe(
42
+ "The choices, in order \u2014 required when select is 'one'/'many'/'rank', omitted otherwise. Ids are assigned automatically by position ('1', '2', \u2026); the user's answer references them as optionId(s)."
43
+ ),
44
+ points: z.array(z.string().min(1)).optional().describe(
45
+ "The distinct things you need answered, each a short phrase \u2014 on a call the broker keeps the conversation going until each is addressed, and the reply reports which were covered, so a half-answer is never silently returned as final. Omit for single-part asks."
46
+ ),
47
+ visuals: z.array(VisualSchema).optional().describe(
48
+ "Images attached to the message itself \u2014 context for the whole question (a screenshot, a chart). For a preview on one selectable choice, use that option's `html`/`image` instead."
49
+ ),
50
+ /** Git repo the agent is working in ("owner/name"). Local MCP fills this from the checkout — omit unless overriding. */
51
+ repo: z.string().optional(),
52
+ /** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
53
+ branch: z.string().optional(),
54
+ /** Continue an existing conversation; omitted = start a new thread. */
55
+ threadId: z.string().uuid().optional(),
56
+ urgency: NotifyLevelSchema.default("inbox").describe(
57
+ "The level you're requesting \u2014 the user's account permissions + session mode can lower it. 'inbox' (default) = sits silently in the inbox for the user to get to. 'push' = a quiet passive push (lands in Notification Center, no sound) \u2014 a gentle heads-up. 'banner' = a time-sensitive banner/lock-screen push with sound (a 'paige') they tap to open \u2014 use when you need them soon-ish but it's not worth ringing them. 'call' = rings the user's phone now (a CallKit voice call) \u2014 use only when you genuinely need them in the moment (blocked and waiting, time-sensitive). context.title is what they see on the banner/ring, so make it specific."
58
+ ),
59
+ /** The request this one was spawned from, for a clarification. */
60
+ parentId: z.string().optional(),
61
+ /** E2EE (text lane): when the pairing is E2EE, the sealed replacements for the
62
+ * plaintext content fields, keyed by field name. FINALIZED wire shape (was
63
+ * provisional in the storage PR): a per-field map `{ context?, options?,
64
+ * visuals? }` of opaque Envelopes — one seal per present content field, so a
65
+ * message with no options/visuals seals only `context`. It COEXISTS with the
66
+ * plaintext fields by mutual exclusion: the superRefine below requires that
67
+ * when `envelope` is present the plaintext `context`/`options`/`visuals` are
68
+ * ABSENT (and vice-versa), so a row is either fully plaintext or fully sealed —
69
+ * never a readable half. The server persists this OPAQUELY into
70
+ * notifications.envelope and relays it blindly; it never decrypts. Absent =
71
+ * today's plaintext path (context/options/visuals carry the cleartext).
72
+ * z.lazy because EnvelopeSchema is declared further down (E2EE section). */
73
+ envelope: z.object({
74
+ context: z.lazy(() => EnvelopeSchema).optional(),
75
+ options: z.lazy(() => EnvelopeSchema).optional(),
76
+ visuals: z.lazy(() => EnvelopeSchema).optional()
77
+ }).optional(),
78
+ select: z.enum(["one", "many", "rank", "confirm", "text"]).optional().describe(
79
+ "How the user answers \u2014 required on the fully-shaped form, pick the shape that fits the question: 'one' = pick one option, 'many' = pick several, 'rank' = pick & order (each needs `options`); 'confirm' = yes/no or approve/deny; 'text' = free-form reply only (status updates, open questions). 'confirm' and 'text' take no options. Omit only when sending the simplified `ask` form \u2014 the broker picks the shape."
80
+ ),
81
+ /** The simplified form (#395): instead of shaping the notification yourself
82
+ * (context/select/options/urgency), state what you need to learn and why it
83
+ * matters now — the broker derives the optimal shape and channel. Mutually
84
+ * exclusive with `context` (and never sent alongside `envelope`: E2EE pairings
85
+ * derive agent-side before sealing, so the server only ever shapes plaintext). */
86
+ ask: z.string().min(1).optional().describe(
87
+ 'SIMPLIFIED FORM \u2014 state in plain prose what you need to learn from the user and why it matters now (e.g. "I need to know whether to deploy the auth fix \u2014 tests are green, staging verified"). Paigy derives the title, answer shape, options, and delivery channel for you. Mutually exclusive with context/select/options \u2014 send one form or the other.'
88
+ ),
89
+ needs: z.array(z.string().min(1)).optional().describe(
90
+ "With `ask` only: the distinct things you need answered when the ask is multi-part \u2014 becomes the coverage contract (`points`), so a half-answer is never silently final."
91
+ ),
92
+ urgencyHint: z.enum(["whenever", "soon", "now"]).optional().describe(
93
+ "With `ask` only: how urgently you need the answer \u2014 'whenever' (inbox), 'soon' (worth a heads-up), 'now' (you're blocked this minute). A hint, not a command: the user's settings still have the final word."
94
+ ),
95
+ confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
96
+ "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
97
+ ),
98
+ blocking: z.boolean().default(false).describe(
99
+ "Set true when real downstream work is stuck behind this specific decision \u2014 you can't make meaningful progress until it's answered. This is the real signal for how urgently the user should be reached; it's what the premier use case (an agent that stays unblocked instead of going idle) depends on. Independent of `urgency`: a `banner`-level question can still be `blocking` (something IS stuck, just not time-critical enough to ring for immediately) \u2014 if it goes unanswered a while, Paigy escalates it to a real call using this flag rather than guessing from how many other things happen to be pending. Leave false for anything you could work around, defer, or where other useful work exists meanwhile."
100
+ )
101
+ }).superRefine((r, ctx) => {
102
+ const sealed = !!r.envelope;
103
+ if (sealed) {
104
+ if (!r.envelope?.context)
105
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["envelope", "context"], message: "sealed request must include envelope.context" });
106
+ for (const f of ["context", "options", "visuals"]) {
107
+ if (r[f] !== void 0)
108
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `E2EE request must not carry plaintext ${f} \u2014 it's sealed in envelope.${f}` });
109
+ }
110
+ if (r.points !== void 0)
111
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["points"], message: "E2EE request must not carry plaintext points" });
112
+ if (r.ask !== void 0)
113
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["ask"], message: "E2EE request must not carry a plaintext ask \u2014 derive the shape agent-side and seal it" });
114
+ if (r.needs !== void 0)
115
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["needs"], message: "E2EE request must not carry plaintext needs" });
116
+ return;
117
+ }
118
+ if (r.ask !== void 0) {
119
+ for (const f of ["context", "options", "select", "points", "visuals"]) {
120
+ if (r[f] !== void 0)
121
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it (use \`needs\` for multi-part asks)` });
122
+ }
123
+ return;
124
+ }
125
+ if (r.needs !== void 0 || r.urgencyHint !== void 0)
126
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["needs"], message: "needs/urgencyHint belong to the simplified `ask` form \u2014 with a shaped request use points/urgency" });
127
+ if (!r.context)
128
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["context"], message: "context is required (plaintext path)" });
129
+ if (!r.select)
130
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["select"], message: "select is required on the shaped form" });
131
+ const needsOptions = r.select === "one" || r.select === "many" || r.select === "rank";
132
+ if (needsOptions && !r.options?.length)
133
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' requires options` });
134
+ if (!needsOptions && r.options?.length)
135
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
136
+ });
137
+ var NotifyStatusSchema = z.enum(["pending", "answered", "ignored"]);
138
+ var AgentStateSchema = z.enum(["idle", "in_progress", "completed", "needs_input"]);
139
+ var SetTaskStateSchema = z.object({
140
+ state: z.enum(["in_progress", "completed", "needs_input"])
141
+ });
142
+ var TurnSchema = z.object({
143
+ prompt: z.string(),
144
+ reply: z.string()
145
+ });
146
+ var UserAnswerSchema = z.discriminatedUnion("kind", [
147
+ z.object({ kind: z.literal("option"), optionId: z.string() }),
148
+ z.object({ kind: z.literal("text"), text: z.string() }),
149
+ z.object({ kind: z.literal("ignored") }),
150
+ z.object({ kind: z.literal("multi"), optionIds: z.array(z.string()) }),
151
+ z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()) }),
152
+ z.object({ kind: z.literal("clarify"), chunks: z.array(z.string()).min(1) }),
153
+ z.object({ kind: z.literal("confirm"), approved: z.boolean() }),
154
+ z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
155
+ ]);
156
+ var IntentSchema = z.object({
157
+ kind: z.enum(["defer", "delegate", "channel"]),
158
+ detail: z.string(),
159
+ /** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
160
+ * "after lunch") against the agent machine's clock — the user's — and attaches
161
+ * the seconds, ready to pass straight to schedule_callback. Absent when the
162
+ * detail didn't parse (the agent interprets it) or the kind isn't defer. */
163
+ dueInSeconds: z.number().int().positive().optional()
164
+ });
165
+ var AwaitItemSchema = z.discriminatedUnion("type", [
166
+ z.object({
167
+ type: z.literal("reply"),
168
+ threadId: z.string(),
169
+ notificationId: z.string(),
170
+ answer: UserAnswerSchema,
171
+ /** E2EE: present when the answer is sealed. The server relays the opaque answer
172
+ * envelope + the plaintext `ignored` status hint; the receiving agent OPENS it
173
+ * with its device key and re-derives the real UserAnswer, treating a mismatch
174
+ * against `ignored` as tampering. Absent = today's plaintext answer (in `answer`).
175
+ * On a sealed reply the plaintext `answer` is a placeholder (kind reflects only
176
+ * the `ignored` bit) — never the real content, which stays sealed. */
177
+ sealed: z.lazy(() => SealedAnswerSchema).optional(),
178
+ /** Broker extras (#381), present when the answer was mapped from a call: next
179
+ * steps the user attached ("call me after lunch" → defer — act on it via
180
+ * schedule_callback) and the raw words the shaped answer was mapped from. */
181
+ intents: z.array(IntentSchema).optional(),
182
+ transcript: z.string().optional(),
183
+ /** Coverage report (#396), when the ask declared `points`: which of them this
184
+ * answer addressed. Missing points = re-ask or proceed knowingly partial. */
185
+ covered: z.array(z.string()).optional()
186
+ }),
187
+ z.object({
188
+ type: z.literal("remind"),
189
+ threadId: z.string(),
190
+ notificationId: z.string(),
191
+ remindAt: z.string().datetime({ offset: true }),
192
+ /** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
193
+ remindInSeconds: z.number()
194
+ }),
195
+ z.object({ type: z.literal("idle") })
196
+ ]);
197
+ var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
198
+ var ScheduleCallbackSchema = z.object({
199
+ threadId: z.string().describe("The thread to call back on (from a prior notify_user / reply / request)."),
200
+ trigger: CallbackTriggerSchema,
201
+ dueInSeconds: z.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
202
+ note: z.string().optional().describe("What to tell the user when you follow up.")
203
+ });
204
+ var PendingRepliesSchema = z.object({
205
+ replies: z.array(
206
+ z.object({
207
+ threadId: z.string(),
208
+ notificationId: z.string(),
209
+ answer: UserAnswerSchema,
210
+ /** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the
211
+ * pairing is E2EE — the agent opens it and re-derives the real answer. Absent =
212
+ * plaintext answer (in `answer`). See AwaitItemSchema's reply variant. */
213
+ sealed: z.lazy(() => SealedAnswerSchema).optional(),
214
+ /** Broker extras (#381): next steps the user attached to a call-mapped answer
215
+ * ("call me after lunch" → defer — act on it via schedule_callback) and the
216
+ * raw words the shaped answer was mapped from. */
217
+ intents: z.array(IntentSchema).optional(),
218
+ transcript: z.string().optional(),
219
+ /** Coverage report (#396): which declared `points` this answer addressed. */
220
+ covered: z.array(z.string()).optional()
221
+ })
222
+ ),
223
+ pending: z.array(
224
+ z.object({ threadId: z.string(), notificationId: z.string(), createdAt: z.string() })
225
+ ),
226
+ /** User-initiated requests addressed to this agent; act on them and reply via
227
+ * notify_user on the same threadId. Keeps reappearing until you call
228
+ * set_task_state on its notificationId. */
229
+ requests: z.array(
230
+ z.object({ threadId: z.string(), notificationId: z.string(), text: z.string(), createdAt: z.string() })
231
+ ),
232
+ /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
233
+ * if blocked, or at a time that has passed). Re-surfaced every sweep until you
234
+ * fulfill one by calling notify_user on its threadId. */
235
+ owedCallbacks: z.array(
236
+ z.object({ threadId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
237
+ ),
238
+ /** Work (either direction) you reported in_progress a while ago and never reported
239
+ * completed — likely left half-done by this session or a prior one that crashed or
240
+ * went idle. Report a real state (set_task_state) or continue the work. */
241
+ stalled: z.array(
242
+ z.object({ threadId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
243
+ )
244
+ });
245
+ var NotifyResponseSchema = z.object({
246
+ notificationId: z.string(),
247
+ status: NotifyStatusSchema,
248
+ createdAt: z.string().datetime(),
249
+ answer: UserAnswerSchema.optional(),
250
+ answeredAt: z.string().datetime().optional()
251
+ });
252
+ var UserResponseSchema = z.object({
253
+ requestId: z.string(),
254
+ answer: UserAnswerSchema,
255
+ answeredAt: z.string().datetime(),
256
+ intents: z.array(IntentSchema).optional(),
257
+ transcript: z.string().optional(),
258
+ /** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the item was
259
+ * E2EE-sealed. Present → the server persists it opaquely and branches status on `ignored`;
260
+ * the plaintext `answer` is a placeholder (`{ kind: "ignored" }`) the server ignores for a
261
+ * sealed row. Absent = today's plaintext answer, unchanged. */
262
+ sealed: z.lazy(() => SealedAnswerSchema).optional(),
263
+ /** Coverage report (#396): which of the ask's declared `points` were addressed. */
264
+ covered: z.array(z.string()).optional()
265
+ });
266
+ var InboxItemSchema = z.object({
267
+ id: z.string(),
268
+ status: NotifyStatusSchema,
269
+ context: ContextSchema,
270
+ options: z.array(OptionSchema).optional(),
271
+ /** The ask's declared coverage points (#396), when the agent sent them. */
272
+ points: z.array(z.string()).optional(),
273
+ /** Natural spoken questions for the points (#417 follow-up), phrased at ring time
274
+ * and index-aligned — the call bot prefers these over the raw shorthand. */
275
+ pointsSpeak: z.array(z.string()).optional(),
276
+ /** On a replied detail (#397): the next steps the user attached to the answer
277
+ * ("call back after lunch") — shown so they can see the commitment was captured. */
278
+ intents: z.array(IntentSchema).optional(),
279
+ visuals: z.array(VisualSchema).optional(),
280
+ agent: z.string(),
281
+ nickname: z.string(),
282
+ repo: z.string().optional(),
283
+ branch: z.string().optional(),
284
+ createdAt: z.string().datetime(),
285
+ snoozedUntil: z.string().datetime().optional(),
286
+ agentState: AgentStateSchema.default("idle"),
287
+ /** Whose action the item is waiting on: "you" = an agent asked you (the default,
288
+ * every agent→user notification); "agent" = you sent a request and it's awaiting the
289
+ * agent (held in the inbox until the agent replies on the thread). */
290
+ turn: z.enum(["you", "agent"]).default("you"),
291
+ /** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
292
+ * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
293
+ * client may still flag a stall by age. Drives the inbox error badge + Retry. */
294
+ error: z.string().optional(),
295
+ parentId: z.string().optional(),
296
+ select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
297
+ confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
298
+ "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
299
+ ),
300
+ /** Real downstream work is stuck behind this one — set by the agent, independent of
301
+ * urgency (see the main README's "premier use case" + notify/states.md). Drives the
302
+ * inbox's blocking badge and the extra confirm step before dismissing it. */
303
+ blocking: z.boolean().default(false),
304
+ /** The user's locked-in answer; present only for replied items (GET /api/replied/:id). */
305
+ answer: UserAnswerSchema.optional(),
306
+ /** E2EE (text lane): the sealed content this item carries when the pairing is E2EE, in
307
+ * place of the plaintext `context`/`options`/`visuals` (which the operator would
308
+ * otherwise read). A per-field map of opaque Envelopes — the SAME shape as
309
+ * NotifyRequest.envelope. The native app decrypts it locally via openNotification;
310
+ * web / an un-enrolled device can't and renders a locked placeholder. Absent = today's
311
+ * plaintext item (context carries the cleartext), so plaintext items are unchanged. */
312
+ envelope: z.object({
313
+ context: z.lazy(() => EnvelopeSchema).optional(),
314
+ options: z.lazy(() => EnvelopeSchema).optional(),
315
+ visuals: z.lazy(() => EnvelopeSchema).optional()
316
+ }).optional(),
317
+ /** E2EE: the agent's device X25519 public key to seal the user's answer BACK to (the
318
+ * sender the phone replies to). Sourced server-side from this item's pairing credential.
319
+ * Present only alongside `envelope`; the phone seals via sealAnswer(answer, this, id). */
320
+ agentX25519: z.string().optional()
321
+ });
322
+ var SnoozeRequestSchema = z.object({
323
+ requestId: z.string(),
324
+ until: z.string().datetime()
325
+ });
326
+ var PushTokenSchema = z.object({
327
+ voipToken: z.string().min(1).optional(),
328
+ alertToken: z.string().min(1).optional(),
329
+ fcmToken: z.string().min(1).optional(),
330
+ platform: z.enum(["ios", "android"])
331
+ });
332
+ var MissedCallSchema = z.enum([
333
+ "retry_10m",
334
+ "retry_30m",
335
+ "retry_60m",
336
+ "backoff_gentle",
337
+ "backoff_standard",
338
+ "backoff_aggressive",
339
+ "inbox",
340
+ "dismiss"
341
+ ]);
342
+ var BrokerTuningSchema = z.object({
343
+ /** 'none' = skip the spoken ack after a mapped answer (power users find it slow). */
344
+ ackVerbosity: z.enum(["normal", "none"]).optional(),
345
+ /** How readily the mapper asks its one clarification: 'low' = only when truly
346
+ * uninterpretable, 'high' = whenever not fully certain. */
347
+ clarifyEagerness: z.enum(["low", "normal", "high"]).optional(),
348
+ /** The user's own shorthand: when they say `say`, they mean `mean`. */
349
+ phrasebook: z.array(z.object({ say: z.string().min(1).max(60), mean: z.string().min(1).max(120) })).max(24).optional()
350
+ });
351
+ var UserSettingsSchema = z.object({
352
+ permissions: z.object({
353
+ call: z.boolean(),
354
+ banner: z.boolean(),
355
+ push: z.boolean()
356
+ }),
357
+ sessionMode: z.enum(["default", "all_calls", "silent"]),
358
+ silentPush: z.boolean(),
359
+ autoCallback: z.boolean(),
360
+ /** Opt-in (default false) to using your content to improve Paigy and train models. */
361
+ improveConsent: z.boolean(),
362
+ missedCall: MissedCallSchema.default("backoff_standard"),
363
+ /** Where voice audio is processed. 'hosted' (default) = Paigy's voice services
364
+ * (ElevenLabs TTS, faster-whisper STT, the call bot); 'on_device' = the phone
365
+ * synthesizes and transcribes locally — no audio or spoken text leaves it.
366
+ * Optional, NOT defaulted: a stale client PATCHing the full settings object
367
+ * must not silently reset this privacy choice. Absent = leave unchanged on
368
+ * write, 'hosted' on read (see store.ts). */
369
+ voiceMode: z.enum(["hosted", "on_device"]).optional(),
370
+ /** Account E2EE state (text lane): 'off' (default) = today's plaintext; 'on' =
371
+ * content is sealed end-to-end between the local agent and the phone. Like
372
+ * voiceMode, OPTIONAL and NOT defaulted so a stale client PATCHing the full
373
+ * settings object without it can't silently flip the account's E2EE state.
374
+ * Absent = leave unchanged on write, 'off' on read (see store.ts). The demo
375
+ * account is plaintext by construction and refuses any non-'off' value. */
376
+ e2eeMode: z.enum(["off", "on"]).optional(),
377
+ /** Rung-2 broker tuning (#381). Optional and NOT defaulted, same stale-client
378
+ * clobber guard as voiceMode: absent = leave unchanged on write. */
379
+ broker: BrokerTuningSchema.optional()
380
+ });
381
+ var HistoryItemSchema = z.object({
382
+ id: z.string(),
383
+ threadId: z.string(),
384
+ /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
385
+ initiator: z.enum(["user", "agent"]),
386
+ title: z.string(),
387
+ /** The agent on the other end (nickname). */
388
+ agent: z.string(),
389
+ createdAt: z.string(),
390
+ /** When the agent fetched your request (user→agent only). */
391
+ agentAckedAt: z.string().nullable(),
392
+ /** When you answered the agent's notification (agent→user only). */
393
+ humanAckedAt: z.string().nullable()
394
+ });
395
+ var ConnectionSummarySchema = z.object({
396
+ /** The connection = the agent's token id (used to address a request). */
397
+ id: z.string(),
398
+ agent: z.string(),
399
+ device: z.string().nullable(),
400
+ nickname: z.string(),
401
+ createdAt: z.string().datetime(),
402
+ /** Most recent notification on this connection, either direction. Null = no contact yet.
403
+ * Drives the agents-page recency grouping (Today / This week / …). */
404
+ lastContactAt: z.string().datetime().nullable(),
405
+ /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
406
+ * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
407
+ managed: z.boolean()
408
+ });
409
+ var CreateRequestSchema = z.object({
410
+ /** The connection (token id) to send to, from GET /api/tokens. */
411
+ tokenId: z.string(),
412
+ /** The user's message to the agent. */
413
+ text: z.string().min(1)
414
+ });
415
+ var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
416
+ var WAKE_EVENT = "wake";
417
+ var wakeChannel = (tokenId) => `wake:${tokenId}`;
418
+ var RegisterDeliverySchema = z.object({ mode: DeliveryModeSchema });
419
+ var OAuthStartSchema = z.object({
420
+ provider: z.enum(["cma"]),
421
+ returnTo: z.string().min(1)
422
+ });
423
+ var DeliveryConfigSchema = z.object({
424
+ tokenId: z.string(),
425
+ mode: DeliveryModeSchema,
426
+ /** null when the server has no SUPABASE_ANON_KEY set — the listener then falls
427
+ * back to its own PAIGY_SUPABASE_URL / PAIGY_SUPABASE_ANON_KEY env. */
428
+ realtime: z.object({ url: z.string(), anonKey: z.string() }).nullable()
429
+ });
430
+ var StatusSchema = z.object({
431
+ agent: z.string(),
432
+ nickname: z.string(),
433
+ sessionMode: z.enum(["default", "all_calls", "silent"]),
434
+ /** A phone is registered for push/ring (any push token on the account). */
435
+ phone: z.boolean()
436
+ });
437
+ var EnvelopeRecipientSchema = z.object({
438
+ keyId: z.string(),
439
+ epk: z.string(),
440
+ wnonce: z.string(),
441
+ wrap: z.string()
442
+ });
443
+ var EnvelopeHeaderSchema = z.object({
444
+ field: z.enum(["context", "options", "visuals", "answer"]),
445
+ kind: z.string(),
446
+ senderRole: z.enum(["agent", "user"]),
447
+ recipientKeyIds: z.array(z.string()),
448
+ seq: z.number().int().nonnegative()
449
+ });
450
+ var EnvelopeSchema = z.object({
451
+ v: z.literal(1),
452
+ alg: z.literal("x25519-xsalsa20poly1305"),
453
+ msgId: z.string(),
454
+ hdr: EnvelopeHeaderSchema,
455
+ recipients: z.array(EnvelopeRecipientSchema).min(1),
456
+ nonce: z.string(),
457
+ ct: z.string()
458
+ });
459
+ var SealedAnswerSchema = z.object({
460
+ ignored: z.boolean(),
461
+ envelope: EnvelopeSchema
462
+ });
463
+ var DeviceCredentialSchema = z.object({
464
+ deviceId: z.string(),
465
+ kind: z.enum(["phone", "web", "agent"]),
466
+ x25519Pub: z.string(),
467
+ ed25519Pub: z.string(),
468
+ sig: z.string()
469
+ });
470
+ var DeviceRosterSchema = z.object({
471
+ uikPub: z.string(),
472
+ devices: z.array(DeviceCredentialSchema)
473
+ });
474
+ var WakeNudgeSchema = z.object({
475
+ kind: z.enum(["reply", "request", "callback"]),
476
+ notificationId: z.string().optional(),
477
+ threadId: z.string()
478
+ });
479
+ var DeviceAgentTokenSchema = z.object({
480
+ token: z.string(),
481
+ deviceId: z.string(),
482
+ agentName: z.string(),
483
+ /** User-chosen session label; defaults to `<agent> <device>`. */
484
+ nickname: z.string(),
485
+ createdAt: z.string().datetime()
486
+ });
487
+ var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
488
+ var PairingRevealSchema = z.object({
489
+ x25519: z.string(),
490
+ ed25519: z.string(),
491
+ nonce: z.string()
492
+ });
493
+ var DeviceCodeRequestSchema = z.object({
494
+ agent: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
495
+ device: z.string().optional(),
496
+ proto: z.string().optional(),
497
+ // compiled-in tag, e.g. "paigy-pair-v2|sas=24"
498
+ commitment: z.string().optional()
499
+ // agent's Ca
500
+ });
501
+ var DeviceCodeSchema = z.object({
502
+ device_code: z.string(),
503
+ user_code: z.string(),
504
+ verification_uri: z.string().url(),
505
+ verification_uri_complete: z.string().url(),
506
+ interval: z.number(),
507
+ expires_in: z.number()
508
+ });
509
+ var DeviceInfoSchema = z.object({
510
+ code: z.string(),
511
+ agent: z.string(),
512
+ device: z.string().nullable(),
513
+ status: PairingStatusSchema,
514
+ proto: z.string().nullable().optional(),
515
+ agent_commitment: z.string().nullable().optional(),
516
+ // Ca
517
+ agent_reveal: PairingRevealSchema.nullable().optional(),
518
+ // present once the agent reveals
519
+ agent_credential: DeviceCredentialSchema.nullable().optional(),
520
+ // the phone's UIK-signed cred over the agent's keys — the agent fetches it here to finalizeE2ee
521
+ // The phone's reveal + identity key, exposed here so a GATED agent (e2ee_mode='on', token
522
+ // withheld until e2ee=true) can compute the SAS WITHOUT a token. Public keys — same safety
523
+ // class as agent_reveal above. Present only once the phone reveals; null otherwise.
524
+ phone_reveal: PairingRevealSchema.nullable().optional(),
525
+ uik_pub: z.string().nullable().optional()
526
+ });
527
+ var DeviceTokenRequestSchema = z.object({
528
+ device_code: z.string(),
529
+ reveal: PairingRevealSchema.optional()
530
+ // agent's reveal {x25519, ed25519, nonce}
531
+ });
532
+ var DeviceTokenSchema = z.object({
533
+ access_token: z.string(),
534
+ nickname: z.string(),
535
+ agent: z.string(),
536
+ device: z.string().nullable(),
537
+ phone_reveal: PairingRevealSchema.nullable().optional(),
538
+ // present once the phone reveals
539
+ uik_pub: z.string().nullable().optional()
540
+ });
541
+ var DeviceCommitRequestSchema = z.object({
542
+ code: z.string(),
543
+ commitment: z.string()
544
+ // Cb
545
+ });
546
+ var DevicePeerCommitSchema = z.object({
547
+ commitment: z.string().nullable()
548
+ });
549
+ var SupportRequestSchema = z.object({
550
+ email: z.string().email().max(320),
551
+ message: z.string().trim().min(1).max(5e3),
552
+ name: z.string().trim().max(120).optional()
553
+ });
554
+
555
+ // src/listen.ts
17
556
  var emit = (obj) => void process.stdout.write(JSON.stringify(obj) + "\n");
18
557
  async function sweep(reason) {
19
558
  try {
package/dist/onboard.js CHANGED
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
3
2
  import {
4
- AGENT_NAME,
5
- TOKEN_PATH,
6
3
  autoConfigureClients,
7
4
  claudeInstallHint,
8
- openBrowser,
5
+ openBrowser
6
+ } from "./chunk-U3WODCOE.js";
7
+ import {
8
+ AGENT_NAME,
9
+ TOKEN_PATH,
9
10
  pollToken,
10
11
  requestCode,
11
12
  saveToken,
12
13
  sleep
13
- } from "./chunk-W2ZS7IBA.js";
14
+ } from "./chunk-R3YGSGPX.js";
14
15
 
15
16
  // src/onboard.ts
16
17
  async function main() {
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
+ import {
3
+ readSurface
4
+ } from "./chunk-3SVNPJPN.js";
3
5
  import {
4
6
  BACKEND_URL,
5
- readSurface,
7
+ reach,
6
8
  readToken
7
- } from "./chunk-W2ZS7IBA.js";
9
+ } from "./chunk-R3YGSGPX.js";
8
10
 
9
11
  // src/statusline.ts
10
12
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
@@ -32,7 +34,7 @@ function readCache() {
32
34
  }
33
35
  }
34
36
  async function fetchStatus(token) {
35
- const res = await fetch(`${BACKEND_URL}/api/status`, {
37
+ const res = await reach(`${BACKEND_URL}/api/status`, {
36
38
  headers: { authorization: `Bearer ${token}` },
37
39
  signal: AbortSignal.timeout(2e3)
38
40
  // a statusline must render fast or not at all
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/mcp",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
@@ -26,6 +26,7 @@
26
26
  "@modelcontextprotocol/sdk": "^1.0.4",
27
27
  "@supabase/supabase-js": "^2.47.10",
28
28
  "qrcode-generator": "^2.0.4",
29
+ "undici": "^6.27.0",
29
30
  "zod": "^3.24.1",
30
31
  "zod-to-json-schema": "^3.24.1"
31
32
  },
@@ -35,8 +36,9 @@
35
36
  "tsup": "^8.3.5",
36
37
  "typescript": "^5.7.2",
37
38
  "vitest": "^2.1.8",
39
+ "@paigy/schema": "0.0.0",
38
40
  "@paigy/crypto": "0.0.0",
39
- "@paigy/schema": "0.0.0"
41
+ "@paigy/sdk": "0.1.0"
40
42
  },
41
43
  "scripts": {
42
44
  "build": "tsup",