@paigy/mcp 0.8.4 → 0.9.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,16 +92,37 @@ Then pair: `npx -p @paigy/mcp@latest paigy-mcp-onboard`.
92
92
 
93
93
  ## Tools
94
94
 
95
- - **`notify_user`** — notify the user (context: title + description chunks, plus optional options — each an answerable choice that can carry a sandboxed `html` or `image` preview for visual "pick one" decisions — visuals, `urgency`, and `parentId` for a clarification). Returns a request id + thread id. If a reply comes back as `{kind:'clarify', chunks:[...]}`, respond via notify_user with the SAME threadId and an expanded description.
96
- - **`await_reply`** — wait for the user's reply to a specific notification (pass its id). Scoped: will not return replies meant for other notifications. Returns `reply` / `remind` / `idle`.
95
+ - **`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.
96
+ - **`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`.
97
97
  - **`check_replies`** — catch-up sweep: returns replies you haven't consumed yet (now marked seen) plus still-pending notifications.
98
- - **`poll_answer`** — fetch the answer to a specific request by id.
98
+ - **`poll_answer`** — fetch the answer to a specific notification by notificationId.
99
99
  - **`set_task_state`** — report progress on a request: `in_progress` / `completed` / `needs_input`.
100
100
 
101
101
  ## Configuration
102
102
 
103
103
  - `PAIGY_BACKEND_URL` — the Paigy API base (defaults to the hosted backend).
104
104
 
105
+ ## Statusline (Claude Code)
106
+
107
+ `paigy-statusline` prints a one-line connection status — the account's session
108
+ mode and whether a phone is paired — for Claude Code's status bar:
109
+
110
+ ```
111
+ paigy: all calls · phone ✓
112
+ ```
113
+
114
+ Enable it in `~/.claude/settings.json`:
115
+
116
+ ```json
117
+ "statusLine": { "type": "command", "command": "npx -y @paigy/mcp@latest paigy-statusline" }
118
+ ```
119
+
120
+ It reads `GET /api/status` with the pairing token and caches the result in
121
+ `~/.paigy/status.json` for 60 seconds, so the bar's constant refreshes never
122
+ hammer the API; when offline it shows the last known state. `paigy: unpaired`
123
+ means there's no token (or the pairing was revoked) — run `/paigy-onboard` to
124
+ pair; `paigy: …` means the status isn't known yet (first run while offline).
125
+
105
126
  ## Publishing (maintainers)
106
127
 
107
128
  Tool input schemas are generated from zod in `src/schema.ts` as draft-2020-12 JSON
@@ -25,32 +25,38 @@ var VisualSchema = z.object({
25
25
  url: z.string().url(),
26
26
  label: z.string().optional()
27
27
  });
28
- var AgentSchema = z.object({
29
- name: z.string()
30
- });
31
28
  var NotifyLevelSchema = z.enum(["inbox", "push", "banner", "call"]);
32
29
  var NotifyRequestSchema = z.object({
33
30
  context: ContextSchema,
34
- options: z.array(OptionSchema).optional(),
35
- visuals: z.array(VisualSchema).optional(),
36
- agent: AgentSchema,
37
- /** Git repo the agent is working in ("owner/name"), when applicable. */
31
+ options: z.array(OptionSchema.omit({ id: true })).min(1).optional().describe(
32
+ "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)."
33
+ ),
34
+ visuals: z.array(VisualSchema).optional().describe(
35
+ "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."
36
+ ),
37
+ /** Git repo the agent is working in ("owner/name"). Local MCP fills this from the checkout — omit unless overriding. */
38
38
  repo: z.string().optional(),
39
- /** Git branch the agent is on, when applicable. */
39
+ /** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
40
40
  branch: z.string().optional(),
41
41
  /** Continue an existing conversation; omitted = start a new thread. */
42
42
  threadId: z.string().uuid().optional(),
43
- createdAt: z.string().datetime(),
44
43
  urgency: NotifyLevelSchema.default("inbox").describe(
45
44
  "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."
46
45
  ),
47
46
  /** The request this one was spawned from, for a clarification. */
48
47
  parentId: z.string().optional(),
49
- /** How the user answers the options: pick one (default), pick several, pick & order, or yes/no confirm. */
50
- select: z.enum(["one", "many", "rank", "confirm"]).default("one"),
48
+ select: z.enum(["one", "many", "rank", "confirm", "text"]).describe(
49
+ "How the user answers \u2014 required, 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."
50
+ ),
51
51
  confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
52
52
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
53
53
  )
54
+ }).superRefine((r, ctx) => {
55
+ const needsOptions = r.select === "one" || r.select === "many" || r.select === "rank";
56
+ if (needsOptions && !r.options?.length)
57
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' requires options` });
58
+ if (!needsOptions && r.options?.length)
59
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
54
60
  });
55
61
  var NotifyStatusSchema = z.enum(["pending", "answered", "ignored"]);
56
62
  var AgentStateSchema = z.enum(["idle", "in_progress", "completed", "needs_input"]);
@@ -115,7 +121,7 @@ var PendingRepliesSchema = z.object({
115
121
  )
116
122
  });
117
123
  var NotifyResponseSchema = z.object({
118
- id: z.string(),
124
+ notificationId: z.string(),
119
125
  status: NotifyStatusSchema,
120
126
  createdAt: z.string().datetime(),
121
127
  answer: UserAnswerSchema.optional(),
@@ -148,7 +154,7 @@ var InboxItemSchema = z.object({
148
154
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
149
155
  error: z.string().optional(),
150
156
  parentId: z.string().optional(),
151
- select: z.enum(["one", "many", "rank", "confirm"]).default("one"),
157
+ select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
152
158
  confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
153
159
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
154
160
  ),
@@ -186,7 +192,14 @@ var UserSettingsSchema = z.object({
186
192
  autoCallback: z.boolean(),
187
193
  /** Opt-in (default false) to using your content to improve Paigy and train models. */
188
194
  improveConsent: z.boolean(),
189
- missedCall: MissedCallSchema.default("backoff_standard")
195
+ missedCall: MissedCallSchema.default("backoff_standard"),
196
+ /** Where voice audio is processed. 'hosted' (default) = Paigy's voice services
197
+ * (ElevenLabs TTS, faster-whisper STT, the call bot); 'on_device' = the phone
198
+ * synthesizes and transcribes locally — no audio or spoken text leaves it.
199
+ * Optional, NOT defaulted: a stale client PATCHing the full settings object
200
+ * must not silently reset this privacy choice. Absent = leave unchanged on
201
+ * write, 'hosted' on read (see store.ts). */
202
+ voiceMode: z.enum(["hosted", "on_device"]).optional()
190
203
  });
191
204
  var HistoryItemSchema = z.object({
192
205
  id: z.string(),
@@ -237,6 +250,13 @@ var DeliveryConfigSchema = z.object({
237
250
  * back to its own PAIGY_SUPABASE_URL / PAIGY_SUPABASE_ANON_KEY env. */
238
251
  realtime: z.object({ url: z.string(), anonKey: z.string() }).nullable()
239
252
  });
253
+ var StatusSchema = z.object({
254
+ agent: z.string(),
255
+ nickname: z.string(),
256
+ sessionMode: z.enum(["default", "all_calls", "silent"]),
257
+ /** A phone is registered for push/ring (any push token on the account). */
258
+ phone: z.boolean()
259
+ });
240
260
  var WakeNudgeSchema = z.object({
241
261
  kind: z.enum(["reply", "request", "callback"]),
242
262
  notificationId: z.string().optional(),
@@ -336,8 +356,8 @@ async function checkReplies() {
336
356
  if (!res.ok) throw new Error(`check_replies failed: ${res.status} ${await res.text()}`);
337
357
  return await res.json();
338
358
  }
339
- async function setTaskState(id, state) {
340
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify/${id}/state`, {
359
+ async function setTaskState(notificationId, state) {
360
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify/${notificationId}/state`, {
341
361
  method: "PATCH",
342
362
  headers: { "content-type": "application/json", authorization: `Bearer ${loadToken()}` },
343
363
  body: JSON.stringify({ state })
@@ -364,9 +384,9 @@ async function scheduleCallback(req) {
364
384
  if (!res.ok) throw new Error(`schedule_callback failed: ${res.status} ${await res.text()}`);
365
385
  return await res.json();
366
386
  }
367
- async function pollAnswer(id) {
387
+ async function pollAnswer(notificationId) {
368
388
  const token = loadToken();
369
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/poll/${id}`, {
389
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/poll/${notificationId}`, {
370
390
  headers: { authorization: `Bearer ${token}` }
371
391
  }));
372
392
  if (!res.ok) throw new Error(`poll failed: ${res.status} ${await res.text()}`);
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  scheduleCallback,
12
12
  setTaskState,
13
13
  submitNotification
14
- } from "./chunk-SLK7NHHQ.js";
14
+ } from "./chunk-L6IEPZCY.js";
15
15
  import {
16
16
  deleteToken,
17
17
  openBrowser,
@@ -61,10 +61,10 @@ function json(s) {
61
61
 
62
62
  // src/index.ts
63
63
  var PollAnswerSchema = z.object({
64
- id: z.string().describe("Request id returned by notify_user.")
64
+ notificationId: z.string().describe("The notificationId returned by notify_user.")
65
65
  });
66
66
  var AwaitReplySchema = z.object({
67
- notificationId: z.string().describe("The id returned by notify_user \u2014 waits for the user's reply to THIS notification only.")
67
+ notificationId: z.string().describe("The notificationId returned by notify_user \u2014 waits for the user's reply to THIS notification only.")
68
68
  });
69
69
  var CheckRepliesSchema = z.object({});
70
70
  var PairSchema = z.object({
@@ -99,7 +99,7 @@ var server = new Server(
99
99
  { name: "paigy", version: "0.0.0" },
100
100
  {
101
101
  capabilities: { tools: {} },
102
- 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 notification's id \u2014 it's scoped to that one request, so it never returns replies meant for other requests. 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 open-ended free text only for answers that genuinely can't be structured (the user can always add free text on top of any shape). If a reply comes back as { kind: 'clarify', chunks: [...] }, the user wants more detail on those chunks \u2014 respond by calling notify_user again with the SAME threadId and an expanded description covering them. 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 text or call 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."
102
+ 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 request, so it never returns replies meant for other requests. 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). If a reply comes back as { kind: 'clarify', chunks: [...] }, the user wants more detail on those chunks \u2014 respond by calling notify_user again with the SAME threadId and an expanded description covering them. 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 text or call 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."
103
103
  }
104
104
  );
105
105
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -116,17 +116,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
116
116
  },
117
117
  {
118
118
  name: "notify_user",
119
- description: "Notify the user via Paigy and get a request id to poll for their answer. 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). 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 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. 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:[...]}. For visual choices give each option a sandboxed `html` or an `image` preview (e.g. layout/UI alternatives). Only leave options off (pure free text / voice) when the answer genuinely can't be structured. Plus optional visuals. 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.",
119
+ description: "Notify the user via Paigy. Returns { notificationId, threadId } \u2014 pass notificationId to await_reply/poll_answer 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). 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. 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:[...]}. 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.",
120
120
  inputSchema: json(NotifyRequestSchema)
121
121
  },
122
122
  {
123
123
  name: "poll_answer",
124
- description: "Fetch the user's answer to a previous notify_user request. Returns status pending | answered | ignored, with the answer once present. Errors if the id is unknown or expired.",
124
+ description: "Fetch the user's answer to a previous notify_user request (pass its notificationId). Returns status pending | answered | ignored, with the answer once present. Errors if the notificationId is unknown or expired.",
125
125
  inputSchema: json(PollAnswerSchema)
126
126
  },
127
127
  {
128
128
  name: "await_reply",
129
- description: "Wait for the user's reply to THE specific notification you sent for the current request (pass its id 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' }. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications/requests, so concurrent requests 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 and any later turns as their follow-up (e.g. the end-of-call 'call me back when it's done / I have a blocking question' reply). 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).",
129
+ description: "Wait for the user's reply to THE specific notification you sent for the current request (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' }. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications/requests, so concurrent requests 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 and any later turns as their follow-up (e.g. the end-of-call 'call me back when it's done / I have a blocking question' reply). 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).",
130
130
  inputSchema: json(AwaitReplySchema)
131
131
  },
132
132
  {
@@ -233,8 +233,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
233
233
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
234
234
  }
235
235
  case "poll_answer": {
236
- const { id } = PollAnswerSchema.parse(request.params.arguments);
237
- const result = await pollAnswer(id);
236
+ const { notificationId } = PollAnswerSchema.parse(request.params.arguments);
237
+ const result = await pollAnswer(notificationId);
238
238
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
239
239
  }
240
240
  case "await_reply": {
package/dist/listen.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  checkReplies,
5
5
  registerDelivery,
6
6
  wakeChannel
7
- } from "./chunk-SLK7NHHQ.js";
7
+ } from "./chunk-L6IEPZCY.js";
8
8
  import "./chunk-GU7C5H6L.js";
9
9
 
10
10
  // src/listen.ts
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ readToken
4
+ } from "./chunk-AILC3H3U.js";
5
+ import {
6
+ BACKEND_URL
7
+ } from "./chunk-GU7C5H6L.js";
8
+
9
+ // src/statusline.ts
10
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ var DIR = join(homedir(), ".paigy");
14
+ var CACHE = join(DIR, "status.json");
15
+ var TTL_MS = 6e4;
16
+ var MODE_LABEL = {
17
+ default: "default",
18
+ all_calls: "all calls",
19
+ silent: "silent"
20
+ };
21
+ function render(status) {
22
+ if (!status) return "paigy: unpaired";
23
+ return `paigy: ${MODE_LABEL[status.sessionMode]} \xB7 ${status.phone ? "phone \u2713" : "no phone"}`;
24
+ }
25
+ function readCache() {
26
+ try {
27
+ return JSON.parse(readFileSync(CACHE, "utf8"));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+ async function fetchStatus(token) {
33
+ const res = await fetch(`${BACKEND_URL}/api/status`, {
34
+ headers: { authorization: `Bearer ${token}` },
35
+ signal: AbortSignal.timeout(2e3)
36
+ // a statusline must render fast or not at all
37
+ });
38
+ if (res.status === 401) return null;
39
+ if (res.status === 404) {
40
+ if ((await res.text()).includes("not_found")) return null;
41
+ throw new Error("status 404");
42
+ }
43
+ if (!res.ok) throw new Error(`status ${res.status}`);
44
+ return await res.json();
45
+ }
46
+ async function main() {
47
+ const token = readToken();
48
+ if (!token) return render(null);
49
+ const cached = readCache();
50
+ if (cached && Date.now() - cached.at < TTL_MS) return render(cached.status);
51
+ try {
52
+ const status = await fetchStatus(token);
53
+ mkdirSync(DIR, { recursive: true });
54
+ writeFileSync(CACHE, JSON.stringify({ at: Date.now(), status }));
55
+ return render(status);
56
+ } catch {
57
+ return cached ? render(cached.status) : "paigy: \u2026";
58
+ }
59
+ }
60
+ if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^file:\/\//, ""))) {
61
+ void main().then((line) => console.log(line));
62
+ }
63
+ export {
64
+ main,
65
+ render
66
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/mcp",
3
- "version": "0.8.4",
3
+ "version": "0.9.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",
@@ -8,7 +8,8 @@
8
8
  "mcp": "./dist/index.js",
9
9
  "paigy-mcp": "./dist/index.js",
10
10
  "paigy-mcp-onboard": "./dist/onboard.js",
11
- "paigy-listen": "./dist/listen.js"
11
+ "paigy-listen": "./dist/listen.js",
12
+ "paigy-statusline": "./dist/statusline.js"
12
13
  },
13
14
  "files": [
14
15
  "dist"
@@ -35,8 +36,8 @@
35
36
  "@paigy/schema": "0.0.0"
36
37
  },
37
38
  "scripts": {
38
- "build": "tsup src/index.ts src/onboard.ts src/listen.ts --format esm --clean",
39
- "dev": "tsup src/index.ts src/onboard.ts src/listen.ts --format esm --watch",
39
+ "build": "tsup src/index.ts src/onboard.ts src/listen.ts src/statusline.ts --format esm --clean",
40
+ "dev": "tsup src/index.ts src/onboard.ts src/listen.ts src/statusline.ts --format esm --watch",
40
41
  "typecheck": "tsc --noEmit",
41
42
  "test": "vitest run"
42
43
  }