@paigy/mcp 0.26.0 → 0.27.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
@@ -141,22 +141,51 @@ wake channel and sweeps on every nudge. Keep it alive past the terminal with
141
141
  removes it).
142
142
 
143
143
  To launch an agent — any agent, not just Claude — when work arrives, set
144
- `PAIGY_ON_WAKE` to a command before `--install`. The daemon runs it with the swept
145
- work in `$PAIGY_WORK` (JSON: `replies`, `requests`, `owedCallbacks`); the sweep has
146
- already claimed the work, so read `$PAIGY_WORK` rather than calling `check_replies`
147
- again. A request carrying a `contextThreadId` (or an unfamiliar `threadId`) means
148
- the user is resuming/seeding a past conversation — rehydrate with `get_thread` first.
144
+ `PAIGY_ON_WAKE` to a command before `--install`. The sweep has already claimed the
145
+ work, so the launcher reads it from the environment rather than calling
146
+ `check_replies` again:
147
+
148
+ | Variable | What it holds |
149
+ | --- | --- |
150
+ | `PAIGY_WORK` | the whole swept payload as JSON (`replies`, `requests`, `owedCallbacks`, `stalled`, `threads`) |
151
+ | `PAIGY_EVENT` | the wake that caused this run — `boot`, `wake:reply`, `cron:callback`… |
152
+ | `PAIGY_THREAD_ID` | the thread to continue on |
153
+ | `PAIGY_NOTIFICATION_ID` | the notification being answered or acted on (unset for an owed callback) |
154
+ | `PAIGY_CONTEXT_THREAD_ID` | a past conversation the user seeded this with — `get_thread` it **first** |
155
+ | `PAIGY_TEXT` | what the user said (or the callback note you owe them), in prose |
156
+
157
+ Hand `$PAIGY_WORK` to a harness that can read JSON and decide for itself; use the
158
+ scalars for a plain shell launcher that shouldn't need `jq`. They describe **one**
159
+ item — the oldest thread without a turn already in flight — because the queue rail's
160
+ contract is one thread at a time. An absent fact is unset rather than empty, so
161
+ `${PAIGY_CONTEXT_THREAD_ID:-}` distinguishes "no seed" from "seeded with nothing".
149
162
 
150
163
  ```sh
151
- # Claude Code:
164
+ # Claude Code — hand it everything and let it plan:
152
165
  PAIGY_ON_WAKE='claude -p "Handle the Paigy work in $PAIGY_WORK — rehydrate threads you do not recognize via get_thread first."' \
153
166
  npx -y -p @paigy/mcp paigy-listen --install
154
167
 
155
- # Codex (or any CLI harness) works the same way:
156
- PAIGY_ON_WAKE='codex exec "Handle the Paigy work in $PAIGY_WORK ..."' \
168
+ # Codex (or any CLI harness) — the scalars are enough for a one-liner:
169
+ PAIGY_ON_WAKE='codex exec "Continue Paigy thread $PAIGY_THREAD_ID. The user said: $PAIGY_TEXT. Call get_thread on it first if you do not recognize it, then reply with contact."' \
157
170
  npx -y -p @paigy/mcp paigy-listen --install
158
171
  ```
159
172
 
173
+ `$PAIGY_TEXT` is whatever the user said, expanded inside a shell command — keep it
174
+ quoted, as above. For anything longer than a one-liner, point `PAIGY_ON_WAKE` at a
175
+ script and branch on `$PAIGY_EVENT` there:
176
+
177
+ ```sh
178
+ #!/bin/sh
179
+ # ~/.paigy/on-wake.sh — chmod +x, then PAIGY_ON_WAKE=~/.paigy/on-wake.sh
180
+ seed=""
181
+ [ -n "${PAIGY_CONTEXT_THREAD_ID:-}" ] && seed="It continues thread $PAIGY_CONTEXT_THREAD_ID — get_thread that first."
182
+
183
+ case "$PAIGY_EVENT" in
184
+ cron:callback*) codex exec "You owe the user a callback on thread $PAIGY_THREAD_ID: $PAIGY_TEXT. Deliver it with contact." ;;
185
+ *) codex exec "Paigy thread $PAIGY_THREAD_ID. The user said: $PAIGY_TEXT. $seed Reply with contact when done." ;;
186
+ esac
187
+ ```
188
+
160
189
  ## Statusline (Claude Code)
161
190
 
162
191
  `paigy-statusline` prints a one-line connection status — the account's session
@@ -212,7 +212,11 @@ var UserAnswerSchema = z.discriminatedUnion("kind", [
212
212
  z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
213
213
  ]);
214
214
  var IntentSchema = z.object({
215
- kind: z.enum(["defer", "delegate", "channel", "question"]),
215
+ // The full vocabulary the bot's mapper emits (mapper.INTENT_KINDS) — the schema lagged
216
+ // it by two ("detail", "feedback"), and because the settle handler parsed the array
217
+ // all-or-nothing, ONE feedback act silently dropped EVERY intent on the call,
218
+ // questions included. Found auditing five calls' stored feedback, 2026-08-01.
219
+ kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command"]),
216
220
  detail: z.string(),
217
221
  /** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
218
222
  * "after lunch") against the agent machine's clock — the user's — and attaches
@@ -382,8 +386,19 @@ var AgendaTurnSchema = z.object({
382
386
  question: z.string().min(1).nullable(),
383
387
  /** True on the one turn carrying the agent's own declared question. */
384
388
  asks: z.boolean().optional(),
389
+ /** The claim this turn belongs to (#781) — the RETURN identity: answers route by it.
390
+ * Absent on a single-claim plan (the session's own claim) and on shared context turns,
391
+ * which route nothing. */
392
+ claimId: z.string().optional(),
393
+ /** The claim's voice key (#462) — the OUTBOUND identity, audible who-is-asking. */
394
+ voice: z.string().optional(),
385
395
  select: SelectShapeSchema.optional(),
386
- options: z.array(OptionSchema.omit({ id: true })).optional()
396
+ options: z.array(OptionSchema.omit({ id: true })).optional(),
397
+ /** The seat was satisfied by the call's own ACCOUNT (replan-design.md, user_info): the
398
+ * caller already answered this claim in an earlier utterance, quoted here VERBATIM —
399
+ * the bot speaks the turn's short confirmation, posts these words as the claim's
400
+ * answer, and never re-asks. Grounded at parse time: an invented settle is #796. */
401
+ settle: z.string().optional()
387
402
  });
388
403
  var InboxItemSchema = z.object({
389
404
  id: z.string(),
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  AGENT_NAME
3
- } from "./chunk-B7SCZUYX.js";
3
+ } from "./chunk-VCEFF2VA.js";
4
4
 
5
5
  // src/clients.ts
6
6
  import { execFile } from "child_process";
@@ -2573,7 +2573,11 @@ var UserAnswerSchema = z.discriminatedUnion("kind", [
2573
2573
  z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
2574
2574
  ]);
2575
2575
  var IntentSchema = z.object({
2576
- kind: z.enum(["defer", "delegate", "channel", "question"]),
2576
+ // The full vocabulary the bot's mapper emits (mapper.INTENT_KINDS) — the schema lagged
2577
+ // it by two ("detail", "feedback"), and because the settle handler parsed the array
2578
+ // all-or-nothing, ONE feedback act silently dropped EVERY intent on the call,
2579
+ // questions included. Found auditing five calls' stored feedback, 2026-08-01.
2580
+ kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command"]),
2577
2581
  detail: z.string(),
2578
2582
  /** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
2579
2583
  * "after lunch") against the agent machine's clock — the user's — and attaches
@@ -2743,8 +2747,19 @@ var AgendaTurnSchema = z.object({
2743
2747
  question: z.string().min(1).nullable(),
2744
2748
  /** True on the one turn carrying the agent's own declared question. */
2745
2749
  asks: z.boolean().optional(),
2750
+ /** The claim this turn belongs to (#781) — the RETURN identity: answers route by it.
2751
+ * Absent on a single-claim plan (the session's own claim) and on shared context turns,
2752
+ * which route nothing. */
2753
+ claimId: z.string().optional(),
2754
+ /** The claim's voice key (#462) — the OUTBOUND identity, audible who-is-asking. */
2755
+ voice: z.string().optional(),
2746
2756
  select: SelectShapeSchema.optional(),
2747
- options: z.array(OptionSchema.omit({ id: true })).optional()
2757
+ options: z.array(OptionSchema.omit({ id: true })).optional(),
2758
+ /** The seat was satisfied by the call's own ACCOUNT (replan-design.md, user_info): the
2759
+ * caller already answered this claim in an earlier utterance, quoted here VERBATIM —
2760
+ * the bot speaks the turn's short confirmation, posts these words as the claim's
2761
+ * answer, and never re-asks. Grounded at parse time: an invented settle is #796. */
2762
+ settle: z.string().optional()
2748
2763
  });
2749
2764
  var InboxItemSchema = z.object({
2750
2765
  id: z.string(),
@@ -3739,6 +3754,7 @@ async function pollAnswer(notificationId) {
3739
3754
  const item = await res.json();
3740
3755
  return item.type === "idle" ? item : decryptItem(item);
3741
3756
  }
3757
+ var AWAIT_WINDOW_MS = 45e3;
3742
3758
  var _partialSeen = /* @__PURE__ */ new Map();
3743
3759
  async function pollPartials(notificationId) {
3744
3760
  const token = readToken();
@@ -3760,11 +3776,12 @@ async function pollPartials(notificationId) {
3760
3776
  }
3761
3777
  async function awaitReply(notificationId, opts = {}) {
3762
3778
  const intervalMs = opts.intervalMs ?? 5e3;
3763
- const windowMs = opts.windowMs ?? 3e5;
3779
+ const windowMs = opts.windowMs ?? AWAIT_WINDOW_MS;
3764
3780
  const doSleep = opts.sleep ?? sleep2;
3765
3781
  const now = opts.now ?? Date.now;
3766
3782
  const start = now();
3767
3783
  while (true) {
3784
+ if (opts.signal?.aborted) return { type: "idle" };
3768
3785
  const item = await pollAnswer(notificationId);
3769
3786
  if (item.type !== "idle") return item;
3770
3787
  const partial = await pollPartials(notificationId);
@@ -3820,6 +3837,15 @@ async function checkReplies() {
3820
3837
  })
3821
3838
  };
3822
3839
  }
3840
+ async function answerCallerQuestion(notificationId, answer) {
3841
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/voice/session-answer`, {
3842
+ method: "POST",
3843
+ headers: { "content-type": "application/json", authorization: `Bearer ${readToken()}` },
3844
+ body: JSON.stringify({ notificationId, answer })
3845
+ }));
3846
+ if (!res.ok) throw new Error(`answer_caller_question failed: ${res.status} ${await res.text()}`);
3847
+ return await res.json();
3848
+ }
3823
3849
  async function setTaskState(notificationId, state) {
3824
3850
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify/${notificationId}/state`, {
3825
3851
  method: "PATCH",
@@ -3888,6 +3914,7 @@ export {
3888
3914
  getThread,
3889
3915
  searchThreads,
3890
3916
  checkReplies,
3917
+ answerCallerQuestion,
3891
3918
  setTaskState,
3892
3919
  registerDelivery,
3893
3920
  scheduleCallback,
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  HandoffSchema
4
- } from "./chunk-KAEJRPN5.js";
4
+ } from "./chunk-47E77CDG.js";
5
5
  import {
6
6
  PAIGY_TOOL_IDS,
7
7
  autoConfigureClients,
8
8
  claudeInstallHint,
9
9
  enablePaigyTools
10
- } from "./chunk-BZPE2ZFE.js";
10
+ } from "./chunk-QXPLQKIM.js";
11
11
  import {
12
12
  clearSurface,
13
13
  writeSurface
@@ -17,6 +17,7 @@ import {
17
17
  ScheduleCallbackSchema,
18
18
  SetTaskStateSchema,
19
19
  UnpairedError,
20
+ answerCallerQuestion,
20
21
  awaitReply,
21
22
  checkReplies,
22
23
  deleteKeyFile,
@@ -39,7 +40,7 @@ import {
39
40
  sleep,
40
41
  startE2ee,
41
42
  submitNotification
42
- } from "./chunk-B7SCZUYX.js";
43
+ } from "./chunk-VCEFF2VA.js";
43
44
 
44
45
  // src/index.ts
45
46
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -220,8 +221,12 @@ function clearPairing(deviceCode) {
220
221
 
221
222
  // src/index.ts
222
223
  var AwaitReplySchema = z.object({
223
- notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only.")
224
+ notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only."),
225
+ maxWaitSeconds: z.number().int().min(5).max(300).optional().describe(
226
+ "How long to hold this ONE call before returning { type:'idle' } so you can loop. Default 45 \u2014 safely under the 60s cap most MCP hosts put on a single tool call. Raise it only if you know your host allows longer; a value past the cap means the call is killed and you get nothing."
227
+ )
224
228
  });
229
+ var JOIN_CAP_MS = 45e3;
225
230
  var PairSchema = z.object({
226
231
  device_code: z.string().optional().describe("Omit to start pairing (returns an approval link to show the user). Pass the device_code from that first call to finish, once the user has approved.")
227
232
  });
@@ -238,6 +243,10 @@ var SetTaskStateToolSchema = z.object({
238
243
  notificationId: z.string(),
239
244
  state: SetTaskStateSchema.shape.state
240
245
  });
246
+ var AnswerCallerQuestionSchema = z.object({
247
+ notificationId: z.string().describe("The notification whose call carried the caller's question \u2014 from the partial turn or the settled reply."),
248
+ answer: z.string().min(1).max(1500).describe("The answer, as one or two short SPOKEN sentences \u2014 it may be read aloud on the live call.")
249
+ });
241
250
  function detectGit() {
242
251
  const run = (cmd) => {
243
252
  try {
@@ -319,7 +328,7 @@ function renderPairOutcome(outcome, device_code) {
319
328
  text: JSON.stringify({
320
329
  status: "pending",
321
330
  device_code,
322
- message: "Still awaiting approval. Call pair again with this device_code to keep waiting."
331
+ message: "Still awaiting approval \u2014 the user hasn't entered the code yet. Call pair again with this device_code RIGHT NOW to keep waiting; each call returns after ~45s so it can't be killed by a host tool-call timeout. Nothing is lost between calls: the code stays valid and approval keeps being polled in the background, so a re-call returns the instant they approve."
323
332
  })
324
333
  }]
325
334
  };
@@ -381,7 +390,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
381
390
  },
382
391
  {
383
392
  name: "await_reply",
384
- description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from contact). 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). While your contact is being handled on a LIVE call, you may receive { type:'partial', inFlight:true, turn } results: what the user said to each turn, as they say it. Use partials to PREPARE \u2014 fetch the data, draft the thing, warm the build \u2014 never to act irreversibly: the user can still revise any of them until the final reply arrives. Partial = intelligence, settled = authorization. If a partial's acts carry a question aimed at you and you know the answer, call contact on the SAME threadId right away \u2014 the caller hears your answer on the same call instead of waiting for a callback. Keep calling await_reply until you get the final reply \u2014 THAT one is the decision. 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 contact 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 (contact with the reply's threadId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' 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 (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped \"should I call you back?\" because the follow-up is implied. `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 (contact on the same threadId) or proceed knowingly partial; never treat a partial answer as complete.",
393
+ description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from contact). This is how you wait for your answer in-context. Polls ~45s per call \u2014 deliberately under the 60s cap most hosts put on a single tool call, so it ALWAYS returns you something (raise it with maxWaitSeconds only if you know your host allows longer). Returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (this window ended, no answer yet). While your contact is being handled on a LIVE call, you may receive { type:'partial', inFlight:true, turn } results: what the user said to each turn, as they say it. Use partials to PREPARE \u2014 fetch the data, draft the thing, warm the build \u2014 never to act irreversibly: the user can still revise any of them until the final reply arrives. Partial = intelligence, settled = authorization. If a partial's acts carry a question aimed at you and you know the answer, call contact on the SAME threadId right away \u2014 the caller hears your answer on the same call instead of waiting for a callback. Keep calling await_reply until you get the final reply \u2014 THAT one is the decision. 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 contact 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 (contact with the reply's threadId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' 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 (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped \"should I call you back?\" because the follow-up is implied. `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 (contact on the same threadId) or proceed knowingly partial; never treat a partial answer as complete.",
385
394
  inputSchema: json(AwaitReplySchema)
386
395
  },
387
396
  {
@@ -404,6 +413,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
404
413
  description: "Report progress on the follow-up work behind ANY notification you own \u2014 a user-initiated request (from check_replies), or your OWN contact question once await_reply/check_replies returns its answer and you start acting on it. Pass that notificationId. THIS is what actually claims/acknowledges a reply or request \u2014 check_replies is a pure read that never consumes anything on its own, so call this as soon as you start engaging with something it returned; otherwise that same item just keeps reappearing forever. States: in_progress (you started working), completed (done), or needs_input (you need more from the user \u2014 usually paired with a contact carrying parentId = the same notificationId you're reporting on). Calling this reliably is also what lets a future session's check_replies surface `stalled` work you (or a crashed/idle prior session) left at in_progress without ever reporting completed.",
405
414
  inputSchema: json(SetTaskStateToolSchema)
406
415
  },
416
+ {
417
+ name: "answer_caller_question",
418
+ description: "Answer a question the user asked DURING a live call, while they're still on it. When a partial turn or a settled reply carries a `question` intent aimed at you, answer it here immediately: if their call is still live, your answer is spoken to them on that same call (returns live: true). If the call already ended (live: false), send the answer as a threaded contact instead \u2014 never drop it. Short spoken sentences only; this may be read aloud.",
419
+ inputSchema: json(AnswerCallerQuestionSchema)
420
+ },
407
421
  {
408
422
  name: "schedule_callback",
409
423
  description: "Promise the user a follow-up you'll keep even if you go idle. Use it when they ask you to report back: trigger 'on_done' (when you finish \u2014 fires when you call set_task_state completed), 'on_blocked' (if you hit a blocker \u2014 fires on set_task_state needs_input), or 'scheduled' with dueInSeconds (e.g. 'remind me in 10 min'). Pass the threadId of the conversation and a short note. Fulfill it by calling contact on that threadId; check_replies re-lists due callbacks until you do.",
@@ -417,9 +431,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
417
431
  ];
418
432
  return { tools };
419
433
  });
420
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
434
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
421
435
  try {
422
- return await handleTool(request);
436
+ return await handleTool(request, extra?.signal);
423
437
  } catch (e) {
424
438
  if (e instanceof UnpairedError) return pairStartResult(await startPairing(suggestedAgentName()));
425
439
  throw e;
@@ -444,14 +458,14 @@ function suggestedAgentName() {
444
458
  if (!raw) return void 0;
445
459
  return CLIENT_LABELS[raw.toLowerCase()] ?? raw.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
446
460
  }
447
- async function handleTool(request) {
461
+ async function handleTool(request, signal) {
448
462
  switch (request.params.name) {
449
463
  case "pair": {
450
464
  const { device_code } = PairSchema.parse(request.params.arguments ?? {});
451
465
  if (!device_code) {
452
466
  return pairStartResult(await startPairing(suggestedAgentName()));
453
467
  }
454
- const capMs = 9e4;
468
+ const capMs = JOIN_CAP_MS;
455
469
  const outcome = await joinBackgroundPair(device_code, capMs) ?? await resolvePairing(device_code, capMs);
456
470
  return renderPairOutcome(outcome, device_code);
457
471
  }
@@ -510,8 +524,11 @@ async function handleTool(request) {
510
524
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
511
525
  }
512
526
  case "await_reply": {
513
- const { notificationId } = AwaitReplySchema.parse(request.params.arguments);
514
- const item = await awaitReply(notificationId);
527
+ const { notificationId, maxWaitSeconds } = AwaitReplySchema.parse(request.params.arguments);
528
+ const item = await awaitReply(notificationId, {
529
+ ...maxWaitSeconds !== void 0 ? { windowMs: maxWaitSeconds * 1e3 } : {},
530
+ ...signal ? { signal } : {}
531
+ });
515
532
  return { content: [{ type: "text", text: JSON.stringify(item) }] };
516
533
  }
517
534
  case "check_replies": {
@@ -526,6 +543,14 @@ async function handleTool(request) {
526
543
  const { q } = SearchThreadsSchema.parse(request.params.arguments);
527
544
  return { content: [{ type: "text", text: JSON.stringify(await searchThreads(q)) }] };
528
545
  }
546
+ case "answer_caller_question": {
547
+ const { notificationId, answer } = AnswerCallerQuestionSchema.parse(request.params.arguments);
548
+ const result = await answerCallerQuestion(notificationId, answer);
549
+ return { content: [{ type: "text", text: JSON.stringify({
550
+ ...result,
551
+ ...result.live ? {} : { note: "The call already ended \u2014 send this as a threaded contact instead so the answer still reaches them." }
552
+ }) }] };
553
+ }
529
554
  case "set_task_state": {
530
555
  const { notificationId, state } = SetTaskStateToolSchema.parse(request.params.arguments);
531
556
  const result = await setTaskState(notificationId, state);
package/dist/listen.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  WAKE_EVENT,
4
4
  wakeChannel
5
- } from "./chunk-KAEJRPN5.js";
5
+ } from "./chunk-47E77CDG.js";
6
6
  import {
7
7
  checkReplies,
8
8
  registerDelivery
9
- } from "./chunk-B7SCZUYX.js";
9
+ } from "./chunk-VCEFF2VA.js";
10
10
 
11
11
  // src/listen.ts
12
12
  import { createClient } from "@supabase/supabase-js";
@@ -113,6 +113,55 @@ function uninstallService() {
113
113
 
114
114
  // src/listen.ts
115
115
  var emit = (obj) => void process.stdout.write(JSON.stringify(obj) + "\n");
116
+ function answerText(answer) {
117
+ switch (answer.kind) {
118
+ case "text":
119
+ return answer.text;
120
+ case "option":
121
+ return answer.label ?? answer.optionId;
122
+ case "multi":
123
+ case "ranked":
124
+ return (answer.labels ?? answer.optionIds).join(", ");
125
+ case "confirm":
126
+ return answer.approved ? "approved" : "declined";
127
+ case "clarify":
128
+ return answer.chunks.join(" ");
129
+ // A call log — PAIGY_TEXT is what the USER said, so the bot's prompts stay out
130
+ // (the full exchange is in PAIGY_WORK for a launcher that wants it).
131
+ case "turns":
132
+ return answer.turns.map((t) => t.reply).join(" ");
133
+ case "ignored":
134
+ return "";
135
+ }
136
+ }
137
+ function launchEnv(work, reason) {
138
+ const env = { PAIGY_WORK: JSON.stringify(work), PAIGY_EVENT: reason };
139
+ const put = (key, value) => {
140
+ if (value) env[key] = value;
141
+ };
142
+ const lead = work.threads?.find((t) => !t.busy)?.items[0]?.notificationId;
143
+ const reply = lead ? work.replies.find((r) => r.notificationId === lead) : work.replies[0];
144
+ const request = lead ? work.requests.find((r) => r.notificationId === lead) : work.requests[0];
145
+ if (reply) {
146
+ put("PAIGY_THREAD_ID", reply.threadId);
147
+ put("PAIGY_NOTIFICATION_ID", reply.notificationId);
148
+ put("PAIGY_TEXT", answerText(reply.answer));
149
+ return env;
150
+ }
151
+ if (request) {
152
+ put("PAIGY_THREAD_ID", request.threadId);
153
+ put("PAIGY_NOTIFICATION_ID", request.notificationId);
154
+ put("PAIGY_CONTEXT_THREAD_ID", request.contextThreadId);
155
+ put("PAIGY_TEXT", request.text);
156
+ return env;
157
+ }
158
+ const callback = work.owedCallbacks[0];
159
+ if (callback) {
160
+ put("PAIGY_THREAD_ID", callback.threadId);
161
+ put("PAIGY_TEXT", callback.note);
162
+ }
163
+ return env;
164
+ }
116
165
  async function sweep(reason) {
117
166
  try {
118
167
  const work = await checkReplies();
@@ -121,7 +170,7 @@ async function sweep(reason) {
121
170
  const inbound = work.replies.length > 0 || work.requests.length > 0;
122
171
  const reengage = work.owedCallbacks.length > 0 && (reason === "boot" || reason.includes("callback"));
123
172
  if (cmd && (inbound || reengage)) {
124
- spawn(cmd, { shell: true, stdio: "inherit", env: { ...process.env, PAIGY_WORK: JSON.stringify(work) } });
173
+ spawn(cmd, { shell: true, stdio: "inherit", env: { ...process.env, ...launchEnv(work, reason) } });
125
174
  }
126
175
  } catch (e) {
127
176
  emit({ type: "error", reason, message: e.message });
@@ -178,5 +227,6 @@ if (isEntry()) {
178
227
  }
179
228
  }
180
229
  export {
230
+ launchEnv,
181
231
  sweep
182
232
  };
package/dist/onboard.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  autoConfigureClients,
4
4
  claudeInstallHint,
5
5
  openBrowser
6
- } from "./chunk-BZPE2ZFE.js";
6
+ } from "./chunk-QXPLQKIM.js";
7
7
  import {
8
8
  AGENT_NAME,
9
9
  TOKEN_PATH,
@@ -11,7 +11,7 @@ import {
11
11
  requestCode,
12
12
  saveToken,
13
13
  sleep
14
- } from "./chunk-B7SCZUYX.js";
14
+ } from "./chunk-VCEFF2VA.js";
15
15
 
16
16
  // src/onboard.ts
17
17
  async function main() {
@@ -6,7 +6,7 @@ import {
6
6
  BACKEND_URL,
7
7
  reach,
8
8
  readToken
9
- } from "./chunk-B7SCZUYX.js";
9
+ } from "./chunk-VCEFF2VA.js";
10
10
 
11
11
  // src/statusline.ts
12
12
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/mcp",
3
- "version": "0.26.0",
3
+ "version": "0.27.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",
@@ -36,9 +36,9 @@
36
36
  "tsup": "^8.3.5",
37
37
  "typescript": "^5.7.2",
38
38
  "vitest": "^2.1.8",
39
+ "@paigy/crypto": "0.0.0",
39
40
  "@paigy/sdk": "0.1.0",
40
- "@paigy/schema": "0.0.0",
41
- "@paigy/crypto": "0.0.0"
41
+ "@paigy/schema": "0.0.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "tsup",