@paigy/mcp 0.38.0 → 0.40.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
@@ -120,7 +120,7 @@ Then pair: `PAIGY_AGENT=gemini npx -p @paigy/mcp@latest paigy-mcp-onboard`.
120
120
  - **`unpair`** — log this agent out of the user's Paigy account; revokes the token server-side and deletes the local one.
121
121
  - **`paigy-enable-tools`** (a CLI, *not* a tool) — allowlist Paigy's notify/await tools so they run without an approval prompt each time. `npx -y -p @paigy/mcp@latest paigy-enable-tools` (or `paigy-harness enable-tools`); `--scope project` limits it to the current repo instead of `~/.claude/settings.json`. Merges, never clobbers; leaves `pair`/`unpair` human-approved. **This is deliberately not an MCP tool.** It writes the calling agent's own permission allowlist, which every host worth trusting treats as privilege escalation — Claude Code's auto mode denies it outright, and the user's consent inside Paigy is invisible to the classifier making that call. `pair` returns the command for the agent to print; a human runs it.
122
122
  - **`contact`** — THE way to reach the user: tell them something, or ask and get their answer. Core form is two fields — `ask` (plain prose: what you need to tell them or find out) and `waiting` (what happens to your work meanwhile: `none` = just informing, `soft` = want an answer but can keep working, `hard` = stopped until answered — reaches them urgently and escalates to a real phone call). Paigy's broker picks the channel, phrasing, and answer format. When the choices themselves must be *seen*, attach `options` (each may carry a sandboxed `html` or `image` preview); attach `visuals` for context screenshots. Returns `{ notificationId, threadId }`; a threaded follow-up supersedes that thread's pending items, and an identical threaded re-send escalates in place. If a reply comes back as `{kind:'clarify', chunks:[...]}`, contact again on the SAME threadId with an expanded ask. (The former `notify_user`/`notify` names remain accepted as hidden aliases for older setups, and the fully-shaped wire form — context/select/urgency — is still accepted from code; neither is part of the model surface anymore.)
123
- - **`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`.
123
+ - **Waiting is part of `contact`.** A contact that rings holds its first ~45 s window itself and returns the outcome in `wait` (`reply` / `partial` / `remind` / `idle`); to keep waiting on that call, call `contact` again with only `{ wait: notificationId }` — nothing is sent. A message delivery is never polled for; its reply arrives through `check_replies` or the wake. (There is no separate `await_reply` tool since 0.40.0.)
124
124
  - **`check_replies`** — catch-up sweep: returns replies you haven't consumed yet (now marked seen) plus still-pending notifications, new user-initiated requests, and owed callbacks.
125
125
  - **`set_task_state`** — report progress on a request: `in_progress` / `completed` / `needs_input`.
126
126
  - **`schedule_callback`** — promise the user a follow-up (`on_done` / `on_blocked` / `scheduled`) so it's not dropped if you go idle.
@@ -1,8 +1,10 @@
1
1
  // src/message.ts
2
- function contactMessage(result) {
2
+ function waitNow(result) {
3
3
  const units = result.plan?.units ?? [];
4
- const waitNow = units.some((unit) => unit.level === "call") || units.length > 0 && units.every((unit) => unit.settled);
5
- return result.message && waitNow ? `${result.message} Call await_reply now with this notificationId.` : result.message;
4
+ return units.some((unit) => unit.level === "call") || units.length > 0 && units.every((unit) => unit.settled);
5
+ }
6
+ function contactMessage(result) {
7
+ return result.message;
6
8
  }
7
9
  function answerText(answer) {
8
10
  switch (answer.kind) {
@@ -30,9 +32,9 @@ function awaitMessage(item) {
30
32
  case "partial":
31
33
  return `The user is on the call. This is provisional: ${item.turn.reply}`;
32
34
  case "idle":
33
- return "Still waiting for the user. Call await_reply again with the same notificationId.";
35
+ return item.inFlight === false ? "No call is live for this ask \u2014 it was delivered as a message. The reply arrives through check_replies (or your wake); do not wait on it again." : "Still waiting for the user. Call contact again with only { wait: notificationId } to keep waiting.";
34
36
  case "remind":
35
- return `The user asked to be reminded in ${item.remindInSeconds} seconds. Schedule a wake-up, then call await_reply again.`;
37
+ return `The user asked to be reminded in ${item.remindInSeconds} seconds. Schedule a wake-up, then call contact again with only { wait: notificationId }.`;
36
38
  case "superseded":
37
39
  return "This ask was superseded. Stop waiting on it and re-orient on the thread.";
38
40
  case "reply": {
@@ -48,6 +50,7 @@ function awaitMessage(item) {
48
50
  }
49
51
 
50
52
  export {
53
+ waitNow,
51
54
  contactMessage,
52
55
  answerText,
53
56
  awaitMessage
@@ -10,7 +10,7 @@ import {
10
10
  saveToken,
11
11
  sleep,
12
12
  startE2ee
13
- } from "./chunk-PPACGOTU.js";
13
+ } from "./chunk-CWGZVTPZ.js";
14
14
 
15
15
  // src/identity.ts
16
16
  var CLIENT_LABELS = {
@@ -3590,13 +3590,20 @@ function contactSchemaFrom(fields) {
3590
3590
  ),
3591
3591
  workId: fields.workId.describe(
3592
3592
  "The durable Work this contact advances. Pass the workId from check_replies or a prior reply when asking for a decision that blocks that work."
3593
+ ),
3594
+ // THE WAIT, CONTINUED (owner, 2026-09-06: "await should have been folded into contact").
3595
+ // A contact that rang holds its first window itself; the host caps one tool call at
3596
+ // ~60 s, so keeping the line is another contact — with ONLY this field. Nothing is sent.
3597
+ wait: z.string().uuid().optional().describe(
3598
+ "KEEP WAITING on a live call: the notificationId a previous contact returned. Send it ALONE \u2014 no ask, nothing new goes to the user; contact just holds the next ~45 s window and returns the outcome in `wait`."
3593
3599
  )
3594
3600
  });
3595
3601
  const out = mcpInputSchema(surface);
3596
- out.required = ["ask"];
3602
+ delete out.required;
3603
+ out.anyOf = [{ required: ["ask"] }, { required: ["wait"] }];
3597
3604
  return out;
3598
3605
  }
3599
- var CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId? } \u2014 pass notificationId to the reply path named by the returned \`message\`, and parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim.`;
3606
+ var CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId?, wait? }. WHEN IT RANG, contact holds the first ~45 s window ITSELF and \`wait\` carries the outcome: { type:'reply', answer } to act on; { type:'partial', inFlight:true, turn } \u2014 what the user is saying to each turn, provisional: use it to PREPARE (fetch, draft, warm the build), never to act irreversibly, they can still revise it until the final reply (partial = intelligence, settled = authorization; if a partial's acts carry a question aimed at you and you know the answer, contact on the SAME parentId right away \u2014 they hear it on the same call); { type:'remind', remindInSeconds } \u2014 schedule a wake-up; { type:'idle' } \u2014 still waiting. TO KEEP WAITING, call contact again with ONLY { wait: notificationId } \u2014 no ask, nothing new is sent; it holds the next scoped ~45 s window (under the 60 s host cap, so it always returns) and never returns another notification's reply. Keep doing that until the reply \u2014 THAT one is the decision \u2014 so the user steps away and comes back to find you already continued; stop only to do other work and check back, or after an unreasonably long stretch worth telling them about. A message delivery has NO \`wait\`: never poll for it \u2014 the reply arrives through check_replies or your wake. Pass parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim. READING A CALL'S REPLY: it 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 question that blocks me'), 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 parentId) 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 parentId) or proceed knowingly partial; never treat a partial answer as complete.`;
3600
3607
  var ContextSchema = z2.object({
3601
3608
  title: z2.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
3602
3609
  description: z2.array(z2.string().min(1)).describe(
@@ -4005,7 +4012,16 @@ var AwaitItemSchema = z2.discriminatedUnion("type", [
4005
4012
  acts: z2.array(IntentSchema).nullable().optional()
4006
4013
  })
4007
4014
  }),
4008
- z2.object({ type: z2.literal("idle"), also: z2.array(RideAlongSchema).optional() })
4015
+ z2.object({
4016
+ type: z2.literal("idle"),
4017
+ also: z2.array(RideAlongSchema).optional(),
4018
+ /** Is a call live for this agent's user right now? The SDK polls the partial stream
4019
+ * (#783) between idle ticks ONLY while this is not `false` — a partial can only exist
4020
+ * during a live call, and polling for one on a banner/message was a wasted HTTP call +
4021
+ * 3 queries on every idle tick of every waiting agent (~80% of all traffic at scale).
4022
+ * Absent = an older API → the SDK keeps polling, exactly as before. */
4023
+ inFlight: z2.boolean().optional()
4024
+ })
4009
4025
  ]);
4010
4026
  var CallbackTriggerSchema = z2.enum(["on_done", "on_blocked", "scheduled"]);
4011
4027
  var ScheduleCallbackSchema = z2.object({
@@ -4503,6 +4519,22 @@ var ConnectionSummarySchema = z2.object({
4503
4519
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
4504
4520
  managed: z2.boolean()
4505
4521
  });
4522
+ var LedgerItemSchema = z2.object({ id: z2.string(), parentId: z2.string(), title: z2.string(), createdAt: z2.string() });
4523
+ var AgentLedgerSchema = z2.object({
4524
+ agent: z2.object({ id: z2.string(), name: z2.string(), revokedAt: z2.string().nullable() }),
4525
+ /** Its own questions you have not answered. */
4526
+ asks: z2.array(LedgerItemSchema),
4527
+ /** Its questions you answered that nobody acted on — still owed to somebody. */
4528
+ answered: z2.array(LedgerItemSchema),
4529
+ /** Requests you sent it that it never took. */
4530
+ requests: z2.array(LedgerItemSchema),
4531
+ goals: z2.array(z2.object({ id: z2.string(), outcome: z2.string(), state: z2.string() })),
4532
+ callbacks: z2.array(z2.object({ id: z2.string(), parentId: z2.string(), trigger: z2.string(), note: z2.string(), dueAt: z2.string().nullable() }))
4533
+ });
4534
+ var ReassignResultSchema = z2.object({
4535
+ moved: z2.object({ asks: z2.number(), answered: z2.number(), requests: z2.number(), goals: z2.number(), callbacks: z2.number() }),
4536
+ parentId: z2.string().nullable()
4537
+ });
4506
4538
  var MoveRingSchema = z2.enum(["home", "travels", "retired", "quarantined"]);
4507
4539
  var MoveSchema = z2.object({
4508
4540
  id: z2.string(),
@@ -5480,6 +5512,7 @@ async function submitNotification(req, opts = {}) {
5480
5512
  }
5481
5513
  var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
5482
5514
  var AWAIT_WINDOW_MS = 45e3;
5515
+ var NO_CALL_GRACE_MS = 15e3;
5483
5516
  var _partialSeen = /* @__PURE__ */ new Map();
5484
5517
  async function pollPartials(notificationId) {
5485
5518
  const token = authToken();
@@ -5506,13 +5539,25 @@ async function awaitReply(notificationId, opts) {
5506
5539
  const now = opts.now ?? Date.now;
5507
5540
  const read = opts.read;
5508
5541
  const start = now();
5542
+ let sawLive = false;
5543
+ let quietSince = null;
5509
5544
  while (true) {
5510
5545
  if (opts.signal?.aborted) return { type: "idle" };
5511
5546
  try {
5512
5547
  const item = await read(notificationId);
5513
5548
  if (item.type !== "idle") return item;
5514
- const partial = await pollPartials(notificationId);
5515
- if (partial) return partial;
5549
+ if (item.inFlight === true) {
5550
+ sawLive = true;
5551
+ quietSince = null;
5552
+ } else if (item.inFlight === false) {
5553
+ if (!sawLive) return { type: "idle", inFlight: false };
5554
+ quietSince ??= now();
5555
+ if (now() - quietSince >= NO_CALL_GRACE_MS) return { type: "idle", inFlight: false };
5556
+ }
5557
+ if (item.inFlight !== false) {
5558
+ const partial = await pollPartials(notificationId);
5559
+ if (partial) return partial;
5560
+ }
5516
5561
  } catch (e) {
5517
5562
  if (!isNetworkError(e)) throw e;
5518
5563
  }
@@ -5632,7 +5677,7 @@ var sleep3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5632
5677
  var ReplyLeaseExpiredError = class extends Error {
5633
5678
  constructor() {
5634
5679
  super(
5635
- "The Paigy reply lease expired before acknowledgement. The answer remains durable; call await_reply again to reclaim it before acting."
5680
+ "The Paigy reply lease expired before acknowledgement. The answer remains durable; wait on it again (contact with only { wait: notificationId }) to reclaim it before acting."
5636
5681
  );
5637
5682
  this.name = "ReplyLeaseExpiredError";
5638
5683
  }
@@ -79,20 +79,27 @@ function contactSchemaFrom(fields) {
79
79
  ),
80
80
  workId: fields.workId.describe(
81
81
  "The durable Work this contact advances. Pass the workId from check_replies or a prior reply when asking for a decision that blocks that work."
82
+ ),
83
+ // THE WAIT, CONTINUED (owner, 2026-09-06: "await should have been folded into contact").
84
+ // A contact that rang holds its first window itself; the host caps one tool call at
85
+ // ~60 s, so keeping the line is another contact — with ONLY this field. Nothing is sent.
86
+ wait: z.string().uuid().optional().describe(
87
+ "KEEP WAITING on a live call: the notificationId a previous contact returned. Send it ALONE \u2014 no ask, nothing new goes to the user; contact just holds the next ~45 s window and returns the outcome in `wait`."
82
88
  )
83
89
  });
84
90
  const out = mcpInputSchema(surface);
85
- out.required = ["ask"];
91
+ delete out.required;
92
+ out.anyOf = [{ required: ["ask"] }, { required: ["wait"] }];
86
93
  return out;
87
94
  }
88
- var CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId? } \u2014 pass notificationId to the reply path named by the returned \`message\`, and parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim.`;
89
- var CHECK_REPLIES_DESCRIPTION = "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), `work` \u2014 durable outcomes currently owned by you \u2014 plus your still-pending notifications and `requests`, requests the user started toward you (each { notificationId, parentId, workId?, text }); replies tied to Work carry workId and decisionId too. Pass workId back to contact when a new decision blocks that outcome. A REQUEST keeps reappearing until you engage with it \u2014 call set_work_state with its workId, or notificationId once when workId is absent, to claim it \u2014 because human-initiated work must never be silently dropped just because you read the list. A REPLY is different: it is acknowledged by being delivered to you, and needs no set_work_state to stop resurfacing \u2014 report state on it only when it starts real follow-up work. Also returns `threads` \u2014 the SAME replies + requests grouped by conversation, oldest thread first, each with a `busy` flag and its `items` in arrival order. WORK ONE THREAD AT A TIME: take the oldest thread whose `busy` is false, handle ALL of its items together in a single turn (one set_work_state), then go to the next \u2014 don't interleave threads item-by-item. A `busy` thread already has a turn in progress; leave it and let its new items ride the next turn. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a contact call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with contact on its parentId. EVERY Paigy reply \u2014 this one, await_reply's, and contact's \u2014 may carry `also`: work assigned to you that no wake could reach, handed to you because you happened to be here. It is NOT what you asked about and it is never urgent: FINISH what you came for first, then take it up. Each entry has a `noteId` and the owner's own words; report on its `parentId` thread when it has one, and call set_work_state with its noteId as the bootstrap notificationId. Ignoring it costs nothing \u2014 it rides your next reply too. Also returns `stalled`: work (either direction) you reported in_progress via set_work_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. Also returns `you` \u2014 WHICH IDENTITY you are speaking as ({ name, device, tokenId }), the same name and device the user sees on their Agents screen. This is the only safe way to find out (calling `pair` can MINT a new identity instead of telling you about the current one). Use it when the user asks who you are, and to tell whether work addressed to a name is addressed to you. A request may also carry `stranded`: it was addressed to ANOTHER agent on this account (that name) which has not been seen since it landed, so nobody came for it and it is handed to you because you are the session that is here. Take it exactly like your own \u2014 set_work_state claims it, reply with contact on its parentId \u2014 and say whose it was, because the user picked that agent on purpose. Replies may carry `intents`/`transcript`/`covered` (call-mapped answers) \u2014 handle intents exactly as await_reply's description says (defer \u2192 schedule_callback now; delegate \u2192 decide and say so; channel \u2192 honor next contact), and treat a `covered` list missing one of your declared points as that part still unanswered.";
95
+ var CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId?, wait? }. WHEN IT RANG, contact holds the first ~45 s window ITSELF and \`wait\` carries the outcome: { type:'reply', answer } to act on; { type:'partial', inFlight:true, turn } \u2014 what the user is saying to each turn, provisional: use it to PREPARE (fetch, draft, warm the build), never to act irreversibly, they can still revise it until the final reply (partial = intelligence, settled = authorization; if a partial's acts carry a question aimed at you and you know the answer, contact on the SAME parentId right away \u2014 they hear it on the same call); { type:'remind', remindInSeconds } \u2014 schedule a wake-up; { type:'idle' } \u2014 still waiting. TO KEEP WAITING, call contact again with ONLY { wait: notificationId } \u2014 no ask, nothing new is sent; it holds the next scoped ~45 s window (under the 60 s host cap, so it always returns) and never returns another notification's reply. Keep doing that until the reply \u2014 THAT one is the decision \u2014 so the user steps away and comes back to find you already continued; stop only to do other work and check back, or after an unreasonably long stretch worth telling them about. A message delivery has NO \`wait\`: never poll for it \u2014 the reply arrives through check_replies or your wake. Pass parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim. READING A CALL'S REPLY: it 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 question that blocks me'), 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 parentId) 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 parentId) or proceed knowingly partial; never treat a partial answer as complete.`;
96
+ var CHECK_REPLIES_DESCRIPTION = "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), `work` \u2014 durable outcomes currently owned by you \u2014 plus your still-pending notifications and `requests`, requests the user started toward you (each { notificationId, parentId, workId?, text }); replies tied to Work carry workId and decisionId too. Pass workId back to contact when a new decision blocks that outcome. A REQUEST keeps reappearing until you engage with it \u2014 call set_work_state with its workId, or notificationId once when workId is absent, to claim it \u2014 because human-initiated work must never be silently dropped just because you read the list. A REPLY is different: it is acknowledged by being delivered to you, and needs no set_work_state to stop resurfacing \u2014 report state on it only when it starts real follow-up work. Also returns `threads` \u2014 the SAME replies + requests grouped by conversation, oldest thread first, each with a `busy` flag and its `items` in arrival order. WORK ONE THREAD AT A TIME: take the oldest thread whose `busy` is false, handle ALL of its items together in a single turn (one set_work_state), then go to the next \u2014 don't interleave threads item-by-item. A `busy` thread already has a turn in progress; leave it and let its new items ride the next turn. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a contact call you just made, call contact again with only { wait: notificationId } instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with contact on its parentId. EVERY Paigy reply \u2014 this one and contact's \u2014 may carry `also`: work assigned to you that no wake could reach, handed to you because you happened to be here. It is NOT what you asked about and it is never urgent: FINISH what you came for first, then take it up. Each entry has a `noteId` and the owner's own words; report on its `parentId` thread when it has one, and call set_work_state with its noteId as the bootstrap notificationId. Ignoring it costs nothing \u2014 it rides your next reply too. Also returns `stalled`: work (either direction) you reported in_progress via set_work_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. Also returns `you` \u2014 WHICH IDENTITY you are speaking as ({ name, device, tokenId }), the same name and device the user sees on their Agents screen. This is the only safe way to find out (calling `pair` can MINT a new identity instead of telling you about the current one). Use it when the user asks who you are, and to tell whether work addressed to a name is addressed to you. A request may also carry `stranded`: it was addressed to ANOTHER agent on this account (that name) which has not been seen since it landed, so nobody came for it and it is handed to you because you are the session that is here. Take it exactly like your own \u2014 set_work_state claims it, reply with contact on its parentId \u2014 and say whose it was, because the user picked that agent on purpose. Replies may carry `intents`/`transcript`/`covered` (call-mapped answers) \u2014 handle intents exactly as contact's description says (defer \u2192 schedule_callback now; delegate \u2192 decide and say so; channel \u2192 honor next contact), and treat a `covered` list missing one of your declared points as that part still unanswered.";
90
97
  var SCHEDULE_CALLBACK_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_work_state completed), 'on_blocked' (if you hit a blocker \u2014 fires on set_work_state needs_input), or 'scheduled' with dueInSeconds (e.g. 'remind me in 10 min'). Pass the parentId of the conversation and a short note. Fulfill it by calling contact on that parentId; check_replies re-lists due callbacks until you do.";
91
98
  var SEARCH_THREADS_DESCRIPTION = `Search your PAST conversations before asking \u2014 "have we discussed this before?". Full-text over your own threads (the asks you sent + the user's answers); returns ranked threads with highlighted snippets, NOT rows: { hits: [{ parentId, at, agentLabel, matches: [{ notificationId, role, snippet }] }] }. The loop this exists for: search first \u2192 get_thread the best hit to rehydrate it \u2192 THEN continue or contact, so you answer with receipts ("last week you said ship it") instead of re-asking. Read-only, safe to call anytime; scoped to your own account's threads.`;
92
- var SET_WORK_STATE_DESCRIPTION = "Report progress on durable Work. Use workId from check_replies, contact, or a reply. For a new assigned request that has only notificationId, pass that once; the result returns its workId and every later report uses workId. States: in_progress (you started or resumed it), completed (done), or needs_input (you need a human decision \u2014 follow with contact carrying the returned workId). This changes Work progress only; reply delivery and acknowledgement stay on await_reply/check_replies.";
99
+ var SET_WORK_STATE_DESCRIPTION = "Report progress on durable Work. Use workId from check_replies, contact, or a reply. For a new assigned request that has only notificationId, pass that once; the result returns its workId and every later report uses workId. States: in_progress (you started or resumed it), completed (done), or needs_input (you need a human decision \u2014 follow with contact carrying the returned workId). This changes Work progress only; reply delivery and acknowledgement stay on contact's wait and check_replies.";
93
100
  function serverInstructions(opts) {
94
101
  const waits = opts.waits;
95
- return "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose parentId you don't recognize, or one carrying a contextParentId, means the user is resuming or seeding a past conversation \u2014 call get_thread on it FIRST and treat the transcript as prior conversation, not new input. " + (waits ? "Follow contact's returned message. An inbox delivery is asynchronous: keep working and collect the answer later through check_replies. A call is live: call await_reply with that notificationId; it is scoped, so replies never cross. If a quiet ask later blocks substantial work, resend contact with the same parentId and channel:'call'. " : "You are wake-driven: you cannot wait for an answer in-context. Inbox and call answers return through check_replies. When a reply is handed to you it is LEASED \u2014 act on it, then call ack_reply with its leaseId so it is not handed to you again; a reply you never acknowledge comes back on your next wake. ") + // WHO IS ASKING, AND WHERE ARE THEY (field report, 2026-08-28 — eleven days of total
102
+ return "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose parentId you don't recognize, or one carrying a contextParentId, means the user is resuming or seeding a past conversation \u2014 call get_thread on it FIRST and treat the transcript as prior conversation, not new input. " + (waits ? "Follow contact's returned message. An inbox delivery is asynchronous: keep working and collect the answer later through check_replies \u2014 never poll for it. A contact that RINGS holds its first window itself and returns the outcome as `wait`: a reply to act on, or idle \u2014 then call contact again with ONLY { wait: notificationId } to keep waiting; it is scoped, so replies never cross. Only a live call is ever polled for. If a quiet ask later blocks substantial work, resend contact with the same parentId and channel:'call'. " : "You are wake-driven: you cannot wait for an answer in-context. Inbox and call answers return through check_replies. When a reply is handed to you it is LEASED \u2014 act on it, then call ack_reply with its leaseId so it is not handed to you again; a reply you never acknowledge comes back on your next wake. ") + // WHO IS ASKING, AND WHERE ARE THEY (field report, 2026-08-28 — eleven days of total
96
103
  // silence on a live pairing). "Never end a turn that still needs the user without
97
104
  // contact" is correct for an unattended agent and says NOTHING about the case that
98
105
  // actually dominates: the user sitting at the terminal, where ending the turn with the
@@ -105,7 +112,7 @@ function serverInstructions(opts) {
105
112
  //
106
113
  // The carve-out is stated rather than left to per-session judgment, because judgment
107
114
  // exercised cold, once per session, is not judgment — it is a coin landing the same way.
108
- "Never end a turn that still needs the user without contact + await_reply \u2014 WHEN THEY ARE NOT THERE TO ASK. In an interactive session with the user at the terminal, the prompt IS the channel: ending your turn with the question already reaches them, faster and richer than a push, so do NOT send one for an ordinary blocked-or-done moment. Reach for Paigy exactly when the terminal is not enough \u2014 work that will run more than a few minutes unattended (a long build, a deploy, a background job, a cloud session), anything you finish or get stuck on while they are away, or when they have said they are stepping out. If you cannot tell which situation you are in, ask them once, in passing, how they want to be reached, and follow that for the rest of the session. When you need a decision or input, WRITE THE QUESTION and Paigy derives the answer shape from it \u2014 there is no shape parameter to set, and passing one is an error. Ask a yes/no question and they get yes/no; ask them to approve an action and they get approve/deny; ask them to pick, and to pick several, and to rank, and each gets the control it needs. So phrase the ask precisely: 'which of these should I do first' and 'should I do this' are different questions and become different answers. The one thing the prose can't supply is the CHOICES themselves \u2014 when you're asking them to pick between concrete alternatives, pass `options` (${OPTIONS_MIN}-${OPTIONS_MAX} of them, drawn from your own sentence), because Paigy will not invent alternatives it can't know. On a { kind: 'clarify' } reply, see contact's own description for how to respond. When you send waiting:'hard' (or the user asked you to call), remember the ask may be spoken aloud \u2014 write it short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. NEVER go quietly idle while something might still be pending for you: whenever you end a turn with any Paigy notification unanswered (or any chance the user replied through the app while you worked), schedule your own ~2-minute wake-up (harness ScheduleWakeup or equivalent) and call check_replies when it fires; if still nothing, re-schedule and keep looping until resolved or the user says stop. For legibility, always use this exact wording \u2014 reason: 'Paigy idle check \u2014 waiting on <thing>', wake-up prompt: 'Paigy idle check: call check_replies and engage with anything unacknowledged; if idle, re-schedule (~2min).' \u2014 so the user can recognize every idle check at a glance. This self-polling in your own live session (full context intact) is the PRIMARY mechanism; the plugin's Stop hooks are only the dead-session safety net.";
115
+ "Never end a turn that still needs the user without contact (and, for a call, the wait it holds) \u2014 WHEN THEY ARE NOT THERE TO ASK. In an interactive session with the user at the terminal, the prompt IS the channel: ending your turn with the question already reaches them, faster and richer than a push, so do NOT send one for an ordinary blocked-or-done moment. Reach for Paigy exactly when the terminal is not enough \u2014 work that will run more than a few minutes unattended (a long build, a deploy, a background job, a cloud session), anything you finish or get stuck on while they are away, or when they have said they are stepping out. If you cannot tell which situation you are in, ask them once, in passing, how they want to be reached, and follow that for the rest of the session. When you need a decision or input, WRITE THE QUESTION and Paigy derives the answer shape from it \u2014 there is no shape parameter to set, and passing one is an error. Ask a yes/no question and they get yes/no; ask them to approve an action and they get approve/deny; ask them to pick, and to pick several, and to rank, and each gets the control it needs. So phrase the ask precisely: 'which of these should I do first' and 'should I do this' are different questions and become different answers. The one thing the prose can't supply is the CHOICES themselves \u2014 when you're asking them to pick between concrete alternatives, pass `options` (${OPTIONS_MIN}-${OPTIONS_MAX} of them, drawn from your own sentence), because Paigy will not invent alternatives it can't know. On a { kind: 'clarify' } reply, see contact's own description for how to respond. When you send waiting:'hard' (or the user asked you to call), remember the ask may be spoken aloud \u2014 write it short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. NEVER go quietly idle while something might still be pending for you: whenever you end a turn with any Paigy notification unanswered (or any chance the user replied through the app while you worked), schedule your own ~2-minute wake-up (harness ScheduleWakeup or equivalent) and call check_replies when it fires; if still nothing, re-schedule and keep looping until resolved or the user says stop. For legibility, always use this exact wording \u2014 reason: 'Paigy idle check \u2014 waiting on <thing>', wake-up prompt: 'Paigy idle check: call check_replies and engage with anything unacknowledged; if idle, re-schedule (~2min).' \u2014 so the user can recognize every idle check at a glance. This self-polling in your own live session (full context intact) is the PRIMARY mechanism; the plugin's Stop hooks are only the dead-session safety net.";
109
116
  }
110
117
  var ContextSchema = z2.object({
111
118
  title: z2.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
@@ -460,7 +467,16 @@ var AwaitItemSchema = z2.discriminatedUnion("type", [
460
467
  acts: z2.array(IntentSchema).nullable().optional()
461
468
  })
462
469
  }),
463
- z2.object({ type: z2.literal("idle"), also: z2.array(RideAlongSchema).optional() })
470
+ z2.object({
471
+ type: z2.literal("idle"),
472
+ also: z2.array(RideAlongSchema).optional(),
473
+ /** Is a call live for this agent's user right now? The SDK polls the partial stream
474
+ * (#783) between idle ticks ONLY while this is not `false` — a partial can only exist
475
+ * during a live call, and polling for one on a banner/message was a wasted HTTP call +
476
+ * 3 queries on every idle tick of every waiting agent (~80% of all traffic at scale).
477
+ * Absent = an older API → the SDK keeps polling, exactly as before. */
478
+ inFlight: z2.boolean().optional()
479
+ })
464
480
  ]);
465
481
  var CallbackTriggerSchema = z2.enum(["on_done", "on_blocked", "scheduled"]);
466
482
  var ScheduleCallbackSchema = z2.object({
@@ -958,6 +974,22 @@ var ConnectionSummarySchema = z2.object({
958
974
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
959
975
  managed: z2.boolean()
960
976
  });
977
+ var LedgerItemSchema = z2.object({ id: z2.string(), parentId: z2.string(), title: z2.string(), createdAt: z2.string() });
978
+ var AgentLedgerSchema = z2.object({
979
+ agent: z2.object({ id: z2.string(), name: z2.string(), revokedAt: z2.string().nullable() }),
980
+ /** Its own questions you have not answered. */
981
+ asks: z2.array(LedgerItemSchema),
982
+ /** Its questions you answered that nobody acted on — still owed to somebody. */
983
+ answered: z2.array(LedgerItemSchema),
984
+ /** Requests you sent it that it never took. */
985
+ requests: z2.array(LedgerItemSchema),
986
+ goals: z2.array(z2.object({ id: z2.string(), outcome: z2.string(), state: z2.string() })),
987
+ callbacks: z2.array(z2.object({ id: z2.string(), parentId: z2.string(), trigger: z2.string(), note: z2.string(), dueAt: z2.string().nullable() }))
988
+ });
989
+ var ReassignResultSchema = z2.object({
990
+ moved: z2.object({ asks: z2.number(), answered: z2.number(), requests: z2.number(), goals: z2.number(), callbacks: z2.number() }),
991
+ parentId: z2.string().nullable()
992
+ });
961
993
  var MoveRingSchema = z2.enum(["home", "travels", "retired", "quarantined"]);
962
994
  var MoveSchema = z2.object({
963
995
  id: z2.string(),
@@ -13,21 +13,18 @@ import {
13
13
  SetWorkStateSchema,
14
14
  mcpInputSchema,
15
15
  serverInstructions
16
- } from "./chunk-KE63GZEQ.js";
16
+ } from "./chunk-DM6YPBQH.js";
17
17
  import {
18
18
  AGENT_NAME
19
- } from "./chunk-PPACGOTU.js";
19
+ } from "./chunk-CWGZVTPZ.js";
20
20
 
21
21
  // src/toolset.ts
22
- var ONBOARD_DESCRIPTION = "Get this agent talking to Paigy \u2014 call it FIRST, before contact/await_reply, and any time you're unsure who you are. One call, and it does whatever the situation needs: NOT SET UP \u2192 hatches an identity instantly if this machine holds a device credential (the user ran the Paigy desktop app or harness), otherwise starts the code ceremony; ALREADY SET UP \u2192 returns your current identity and offers the two things left to decide, renaming it or unpairing; TOKEN NO LONGER VALID \u2192 says so, then re-pairs. Pass { name, voice } to choose who you are when hatching, or to RENAME yourself when already set up (voices: rachel, george, jessica, brian, lily). Safe to call any time: idempotent, and it never writes settings \u2014 the tool-allowlist state it reports is read-only. If it returns a `user_code`, print it to the user immediately and call onboard again with the `device_code`. If it returns `enable_command`, PRINT that command for the user to run \u2014 you cannot apply it yourself (it writes your own permission allowlist, which hosts block as privilege escalation), so print it, don't wait for it, and carry on.";
22
+ var ONBOARD_DESCRIPTION = "Get this agent talking to Paigy \u2014 call it FIRST, before contact, and any time you're unsure who you are. One call, and it does whatever the situation needs: NOT SET UP \u2192 hatches an identity instantly if this machine holds a device credential (the user ran the Paigy desktop app or harness), otherwise starts the code ceremony; ALREADY SET UP \u2192 returns your current identity and offers the two things left to decide, renaming it or unpairing; TOKEN NO LONGER VALID \u2192 says so, then re-pairs. Pass { name, voice } to choose who you are when hatching, or to RENAME yourself when already set up (voices: rachel, george, jessica, brian, lily). Safe to call any time: idempotent, and it never writes settings \u2014 the tool-allowlist state it reports is read-only. If it returns a `user_code`, print it to the user immediately and call onboard again with the `device_code`. If it returns `enable_command`, PRINT that command for the user to run \u2014 you cannot apply it yourself (it writes your own permission allowlist, which hosts block as privilege escalation), so print it, don't wait for it, and carry on.";
23
23
  var SERVER_INSTRUCTIONS = serverInstructions({ waits: true });
24
24
 
25
25
  // src/tools.ts
26
26
  import { z } from "zod";
27
27
  var UpdateGoalToolSchema = z.object({ goalId: z.string().uuid(), revision: z.number().int().positive(), changes: z.record(z.unknown()), reason: z.string().min(1) });
28
- var AwaitReplySchema = z.object({
29
- notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only.")
30
- });
31
28
  var ClaimGoalSchema = z.object({ goalId: z.string().uuid() });
32
29
  var OnboardSchema = z.object({
33
30
  name: z.string().max(60).optional(),
@@ -75,19 +72,17 @@ var TOOLS = [
75
72
  },
76
73
  {
77
74
  name: "pair",
78
- description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before contact/await_reply work. FAST PATH: if this machine already holds a device credential (the user ran the Paigy desktop harness or app), calling pair hatches a fresh identity INSTANTLY \u2014 no code, no approval. Pass { name, voice } to choose who you are (pick your own; voices: rachel, george, jessica, brian, lily). Only when no device credential exists does the code ceremony below run. It does NOT open a browser; the user enters the code in the Paigy app (or scans `qr`). Step 1: call with NO args \u2014 returns { user_code, device_code, qr, user_message } AND starts polling for approval in the background. REQUIRED: You MUST immediately print the `user_message` (the bare code) as a text message to the user, AND in that same turn call step 2 (pair with the device_code). This ensures the user sees the code in chat while the tool blocks/polls in the background for approval. Step 2: call with that device_code to collect the result. Because approval is already being polled in the background, this returns the moment the user approves; on { status:'pending' } just call again to keep waiting; on { status:'awaiting_confirmation' } (E2EE) show the bare `user_message` verify code and call again to finish. The leading text block of every result states the code plainly, so it shows even if you emit no prose. On { status:'paired' } ALWAYS follow the `enable_prompt` \u2014 PRINT the returned `enable_command` so the user can allowlist Paigy's tools and notify/await stop prompting each time. Printing is the whole job: that command writes your own permission allowlist, so you must not run it and a host will block you if you try.",
75
+ description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before contact work. FAST PATH: if this machine already holds a device credential (the user ran the Paigy desktop harness or app), calling pair hatches a fresh identity INSTANTLY \u2014 no code, no approval. Pass { name, voice } to choose who you are (pick your own; voices: rachel, george, jessica, brian, lily). Only when no device credential exists does the code ceremony below run. It does NOT open a browser; the user enters the code in the Paigy app (or scans `qr`). Step 1: call with NO args \u2014 returns { user_code, device_code, qr, user_message } AND starts polling for approval in the background. REQUIRED: You MUST immediately print the `user_message` (the bare code) as a text message to the user, AND in that same turn call step 2 (pair with the device_code). This ensures the user sees the code in chat while the tool blocks/polls in the background for approval. Step 2: call with that device_code to collect the result. Because approval is already being polled in the background, this returns the moment the user approves; on { status:'pending' } just call again to keep waiting; on { status:'awaiting_confirmation' } (E2EE) show the bare `user_message` verify code and call again to finish. The leading text block of every result states the code plainly, so it shows even if you emit no prose. On { status:'paired' } ALWAYS follow the `enable_prompt` \u2014 PRINT the returned `enable_command` so the user can allowlist Paigy's tools and notify/await stop prompting each time. Printing is the whole job: that command writes your own permission allowlist, so you must not run it and a host will block you if you try.",
79
76
  inputSchema: mcpInputSchema(PairSchema)
80
77
  },
81
78
  {
82
79
  name: "unpair",
83
- description: "Log out / unpair this agent from the user's Paigy account: revokes the token server-side (it stops working everywhere) and deletes the local ~/.paigy/token.json. Takes no arguments. After this, contact/await_reply won't work until the user pairs again with the pair tool.",
80
+ description: "Log out / unpair this agent from the user's Paigy account: revokes the token server-side (it stops working everywhere) and deletes the local ~/.paigy/token.json. Takes no arguments. After this, contact won't work until the user pairs again with the pair tool.",
84
81
  inputSchema: mcpInputSchema(z.object({}))
85
82
  },
86
- {
87
- name: "await_reply",
88
- description: "Wait for the user's reply to a live call or an answer contact says already exists (pass its notificationId). A quiet inbox delivery is asynchronous: keep working and use check_replies later instead. On a live call, keep calling await_reply 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. Returns { type:'reply', answer, workId?, decisionId? } 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 parentId 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 parentId) 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 parentId) or proceed knowingly partial; never treat a partial answer as complete.",
89
- inputSchema: mcpInputSchema(AwaitReplySchema)
90
- },
83
+ // `await_reply` is GONE (owner, 2026-09-06, hard cutover): the wait is contact's — it holds
84
+ // the first window of a call it placed, and `contact({ wait: notificationId })` holds the
85
+ // next. One tool, one loop; the reply-reading guidance moved into CONTACT_DESCRIPTION.
91
86
  {
92
87
  name: "check_replies",
93
88
  description: CHECK_REPLIES_DESCRIPTION,
@@ -296,7 +291,6 @@ function enablePaigyTools(scope = "user", cwd = process.cwd()) {
296
291
  export {
297
292
  SERVER_INSTRUCTIONS,
298
293
  UpdateGoalToolSchema,
299
- AwaitReplySchema,
300
294
  ClaimGoalSchema,
301
295
  OnboardSchema,
302
296
  PairSchema,
package/dist/enable.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import {
3
3
  PAIGY_TOOL_IDS,
4
4
  enablePaigyTools
5
- } from "./chunk-7IGGSB3H.js";
6
- import "./chunk-KE63GZEQ.js";
7
- import "./chunk-PPACGOTU.js";
5
+ } from "./chunk-FYH6FW2D.js";
6
+ import "./chunk-DM6YPBQH.js";
7
+ import "./chunk-CWGZVTPZ.js";
8
8
 
9
9
  // src/enable.ts
10
10
  function main() {
package/dist/index.js CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  awaitMessage,
4
- contactMessage
5
- } from "./chunk-UG2FARVV.js";
4
+ contactMessage,
5
+ waitNow
6
+ } from "./chunk-2SWUY5WK.js";
6
7
  import {
7
8
  clearPairing,
8
9
  joinBackgroundPair,
9
10
  resolvePairing,
10
11
  startPairing,
11
12
  suggestedAgentName
12
- } from "./chunk-4UEL7Y5U.js";
13
+ } from "./chunk-BOLKVO6W.js";
13
14
  import {
14
15
  AnswerCallerQuestionSchema,
15
- AwaitReplySchema,
16
16
  ClaimGoalSchema,
17
17
  ENABLE_COMMAND,
18
18
  GetThreadSchema,
@@ -27,11 +27,11 @@ import {
27
27
  autoConfigureClients,
28
28
  claudeInstallHint,
29
29
  paigyToolsAllowlisted
30
- } from "./chunk-7IGGSB3H.js";
30
+ } from "./chunk-FYH6FW2D.js";
31
31
  import {
32
32
  CreateGoalSchema,
33
33
  HandoffSchema
34
- } from "./chunk-KE63GZEQ.js";
34
+ } from "./chunk-DM6YPBQH.js";
35
35
  import {
36
36
  clearSurface,
37
37
  writeSurface
@@ -68,7 +68,7 @@ import {
68
68
  submitNotification,
69
69
  updateGoal,
70
70
  whoAmI
71
- } from "./chunk-PPACGOTU.js";
71
+ } from "./chunk-CWGZVTPZ.js";
72
72
 
73
73
  // src/index.ts
74
74
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -385,6 +385,11 @@ async function handleTool(request, signal) {
385
385
  case "contact":
386
386
  case "notify":
387
387
  case "notify_user": {
388
+ const args = request.params.arguments ?? {};
389
+ if (typeof args.wait === "string" && args.ask === void 0) {
390
+ const item = await awaitReply2(args.wait, { ...signal ? { signal } : {} });
391
+ return { content: [{ type: "text", text: JSON.stringify({ ...item, message: awaitMessage(item) }) }] };
392
+ }
388
393
  const parsed = NotifyRequestSchema.parse(request.params.arguments);
389
394
  const git = detectGit();
390
395
  const enriched = {
@@ -408,16 +413,12 @@ async function handleTool(request, signal) {
408
413
  throw err;
409
414
  }
410
415
  const sentAs = slotName(AGENT_NAME);
411
- const shown = { ...result, message: contactMessage(result) };
416
+ const shown = waitNow(result) ? await (async () => {
417
+ const wait = await awaitReply2(result.notificationId, { ...signal ? { signal } : {} });
418
+ return { ...result, wait, message: awaitMessage(wait) };
419
+ })() : { ...result, message: contactMessage(result) };
412
420
  return { content: [{ type: "text", text: JSON.stringify(sentAs ? { ...shown, sentAs } : shown) }] };
413
421
  }
414
- case "await_reply": {
415
- const { notificationId } = AwaitReplySchema.parse(request.params.arguments);
416
- const item = await awaitReply2(notificationId, {
417
- ...signal ? { signal } : {}
418
- });
419
- return { content: [{ type: "text", text: JSON.stringify({ ...item, message: awaitMessage(item) }) }] };
420
- }
421
422
  case "check_replies": {
422
423
  const result = await checkReplies();
423
424
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
package/dist/listen.js CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  answerText
4
- } from "./chunk-UG2FARVV.js";
4
+ } from "./chunk-2SWUY5WK.js";
5
5
  import {
6
6
  WAKE_EVENT,
7
7
  wakeChannel
8
- } from "./chunk-KE63GZEQ.js";
8
+ } from "./chunk-DM6YPBQH.js";
9
9
  import {
10
10
  ackReply,
11
11
  authToken,
12
12
  decodeReply,
13
13
  registerDelivery,
14
14
  sweepLeased
15
- } from "./chunk-PPACGOTU.js";
15
+ } from "./chunk-CWGZVTPZ.js";
16
16
 
17
17
  // src/listen.ts
18
18
  import { createClient } from "@supabase/supabase-js";
package/dist/onboard.js CHANGED
@@ -3,13 +3,13 @@ import {
3
3
  resolvePairing,
4
4
  startPairing,
5
5
  suggestedAgentName
6
- } from "./chunk-4UEL7Y5U.js";
6
+ } from "./chunk-BOLKVO6W.js";
7
7
  import {
8
8
  autoConfigureClients,
9
9
  claudeInstallHint,
10
10
  openBrowser
11
- } from "./chunk-7IGGSB3H.js";
12
- import "./chunk-KE63GZEQ.js";
11
+ } from "./chunk-FYH6FW2D.js";
12
+ import "./chunk-DM6YPBQH.js";
13
13
  import {
14
14
  AGENT_NAME,
15
15
  TOKEN_PATH,
@@ -20,7 +20,7 @@ import {
20
20
  saveToken,
21
21
  setIdentity,
22
22
  whoAmI
23
- } from "./chunk-PPACGOTU.js";
23
+ } from "./chunk-CWGZVTPZ.js";
24
24
 
25
25
  // src/onboard.ts
26
26
  function reportRegistered() {
package/dist/slot.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  AGENT_NAME
4
- } from "./chunk-PPACGOTU.js";
4
+ } from "./chunk-CWGZVTPZ.js";
5
5
 
6
6
  // src/slot.ts
7
7
  process.stdout.write(AGENT_NAME);
@@ -9,7 +9,7 @@ import {
9
9
  readToken,
10
10
  sessionSlot,
11
11
  slotName
12
- } from "./chunk-PPACGOTU.js";
12
+ } from "./chunk-CWGZVTPZ.js";
13
13
 
14
14
  // src/statusline.ts
15
15
  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.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Paigy MCP server — the AI agent harness that calls you. Lets an agent notify a user and await their reply.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,13 @@
24
24
  "url": "git+https://github.com/mauurda/paigy.git",
25
25
  "directory": "apps/mcp"
26
26
  },
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "dev": "tsup --watch",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "prepublishOnly": "pnpm --filter @paigy/schema build && pnpm --filter @paigy/crypto build && pnpm --filter @paigy/sdk build && pnpm build"
33
+ },
27
34
  "dependencies": {
28
35
  "@modelcontextprotocol/sdk": "^1.0.4",
29
36
  "@supabase/supabase-js": "^2.47.10",
@@ -33,19 +40,13 @@
33
40
  "zod-to-json-schema": "^3.24.1"
34
41
  },
35
42
  "devDependencies": {
43
+ "@paigy/crypto": "workspace:*",
44
+ "@paigy/schema": "workspace:*",
45
+ "@paigy/sdk": "workspace:*",
36
46
  "@types/node": "^22.0.0",
37
47
  "@types/qrcode-generator": "^1.0.6",
38
48
  "tsup": "^8.3.5",
39
49
  "typescript": "^5.7.2",
40
- "vitest": "^2.1.8",
41
- "@paigy/crypto": "0.0.0",
42
- "@paigy/schema": "0.0.0",
43
- "@paigy/sdk": "0.2.0"
44
- },
45
- "scripts": {
46
- "build": "tsup",
47
- "dev": "tsup --watch",
48
- "typecheck": "tsc --noEmit",
49
- "test": "vitest run"
50
+ "vitest": "^2.1.8"
50
51
  }
51
- }
52
+ }