@paigy/mcp 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/enable.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import {
3
3
  PAIGY_TOOL_IDS,
4
4
  enablePaigyTools
5
- } from "./chunk-KNMI3CJ3.js";
6
- import "./chunk-U6SLIORK.js";
5
+ } from "./chunk-PN7FDW26.js";
6
+ import "./chunk-242XN7FD.js";
7
+ import "./chunk-VXHTA4PB.js";
7
8
 
8
9
  // src/enable.ts
9
10
  function main() {
package/dist/index.js CHANGED
@@ -1,15 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- HandoffSchema,
4
- MISSED_CALL_PLAN
5
- } from "./chunk-YOGAG4YY.js";
3
+ awaitMessage,
4
+ contactMessage
5
+ } from "./chunk-UG2FARVV.js";
6
6
  import {
7
+ clearPairing,
8
+ joinBackgroundPair,
9
+ resolvePairing,
10
+ startPairing,
11
+ suggestedAgentName
12
+ } from "./chunk-V66NUNSQ.js";
13
+ import {
14
+ AnswerCallerQuestionSchema,
15
+ AwaitReplySchema,
16
+ ClaimGoalSchema,
7
17
  ENABLE_COMMAND,
18
+ GetThreadSchema,
19
+ OnboardSchema,
8
20
  PAIGY_TOOL_IDS,
21
+ PairSchema,
22
+ SERVER_INSTRUCTIONS,
23
+ SearchThreadsSchema,
24
+ SetTaskStateToolSchema,
25
+ TOOLS,
26
+ UpdateGoalToolSchema,
9
27
  autoConfigureClients,
10
28
  claudeInstallHint,
11
29
  paigyToolsAllowlisted
12
- } from "./chunk-KNMI3CJ3.js";
30
+ } from "./chunk-PN7FDW26.js";
31
+ import {
32
+ CreateGoalSchema,
33
+ HandoffSchema
34
+ } from "./chunk-242XN7FD.js";
13
35
  import {
14
36
  clearSurface,
15
37
  writeSurface
@@ -18,40 +40,35 @@ import {
18
40
  AGENT_NAME,
19
41
  NotifyRequestSchema,
20
42
  ScheduleCallbackSchema,
21
- SetTaskStateSchema,
43
+ SetWorkStateSchema,
22
44
  UnpairedError,
23
45
  answerCallerQuestion,
24
- awaitReply,
25
- checkReplies2,
46
+ awaitReply2,
47
+ checkReplies,
48
+ claimGoal,
49
+ contactParentId,
50
+ createGoal,
26
51
  deleteKeyFile,
27
52
  deleteToken,
28
- fetchCredential,
29
- finalizeE2ee,
30
53
  getThread,
31
54
  handoff,
32
55
  hatch,
33
56
  heartbeat,
34
- lintNotify,
35
57
  listSlots,
36
- normalizeSpeech,
37
- overrideToken2,
38
- pairStep,
39
- readKeyFile,
58
+ overrideToken,
40
59
  readToken,
41
- requestCode,
42
60
  revokeToken,
43
- saveKeyFile,
44
61
  saveToken,
45
62
  scheduleCallback,
46
63
  searchThreads,
47
64
  setIdentity,
48
65
  setTaskState,
49
- sleep,
66
+ setWorkState,
50
67
  slotName,
51
- startE2ee,
52
68
  submitNotification,
69
+ updateGoal,
53
70
  whoAmI
54
- } from "./chunk-U6SLIORK.js";
71
+ } from "./chunk-VXHTA4PB.js";
55
72
 
56
73
  // src/index.ts
57
74
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -61,220 +78,19 @@ import {
61
78
  ListToolsRequestSchema
62
79
  } from "@modelcontextprotocol/sdk/types.js";
63
80
  import { execSync } from "child_process";
64
- import { z } from "zod";
65
81
  import qrcode from "qrcode-generator";
66
-
67
- // src/schema.ts
68
- import { zodToJsonSchema } from "zod-to-json-schema";
69
- function draft2020(node) {
70
- if (Array.isArray(node)) return node.map(draft2020);
71
- if (node && typeof node === "object") {
72
- const o = node;
73
- for (const [excl, lim] of [["exclusiveMinimum", "minimum"], ["exclusiveMaximum", "maximum"]]) {
74
- if (typeof o[excl] === "boolean") {
75
- if (o[excl] === true && typeof o[lim] === "number") {
76
- o[excl] = o[lim];
77
- delete o[lim];
78
- } else delete o[excl];
79
- }
80
- }
81
- for (const k of Object.keys(o)) o[k] = draft2020(o[k]);
82
- return o;
83
- }
84
- return node;
85
- }
86
- function json(s) {
87
- const schema = zodToJsonSchema(s, { target: "jsonSchema2019-09", $refStrategy: "none" });
88
- delete schema.$schema;
89
- return draft2020(schema);
90
- }
91
-
92
- // src/toolset.ts
93
- var fmtMin = (m) => m >= 60 ? `${m / 60} hr` : `${m} min`;
94
- var STANDARD_MEANS = (() => {
95
- const plan = MISSED_CALL_PLAN.backoff_standard;
96
- const mins = plan.kind === "at" ? plan.minutes : [];
97
- const parts = mins.map(fmtMin);
98
- const list = parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}` : parts[0] ?? "";
99
- return `Rings again ${list} after the missed call, then leaves it in your inbox`;
100
- })();
101
- var CONTACT_SCHEMA = {
102
- type: "object",
103
- properties: {
104
- ask: {
105
- type: "string",
106
- description: `What to tell the user, or what you need to find out from them. Plain prose \u2014 as long as it needs to be (up to 10k characters); Paigy splits it into topics and reads back a few sentences at a time, so do NOT compress a briefing into one line. May be spoken aloud on a call, so write natural speech and name things (not IDs). Contact at exactly two moments: BLOCKED on a decision only they can make, or DONE (one short report \u2014 what shipped, how you verified it, what you flagged). DONE IS SAID ONCE: "all set", "nothing open on my end", "that thread is complete" are the same report in new words, and each one reaches them separately (live 2026-08-12: three of them in three minutes). After the first, you are finished speaking; if they acknowledge it, stop rather than confirming the acknowledgement. Progress is never a contact: set_task_state carries it, and working narration stays in your own terminal \u2014 the user sees you're working without being interrupted by it.`
107
- },
108
- waiting: {
109
- type: "string",
110
- enum: ["none", "soft", "hard"],
111
- description: "What happens to your work while you wait. 'none': you're just informing them. 'soft': you'd like an answer but can keep working. 'hard': you are STOPPED until they answer \u2014 reaches them urgently and escalates to a real phone call if unanswered."
112
- },
113
- options: {
114
- type: "array",
115
- items: {
116
- type: "object",
117
- properties: {
118
- label: { type: "string" },
119
- html: {
120
- type: "string",
121
- description: "Optional sandboxed HTML/CSS preview (no JS, no network, inline CSS + data: URIs, \u226416KB)."
122
- },
123
- image: { type: "string", description: "Optional hosted image URL shown as this option's preview." }
124
- },
125
- required: ["label"]
126
- },
127
- description: "The choices the user picks from, when you have them."
128
- },
129
- channel: {
130
- type: "string",
131
- enum: ["call", "message"],
132
- description: "Only if the user explicitly said how to reach them \u2014 'call me' \u2192 'call', 'just message/text me' \u2192 'message'. Omit otherwise; Paigy picks."
133
- },
134
- parentId: {
135
- type: "string",
136
- description: "To continue an earlier conversation, pass the parentId a previous contact or reply returned. Omit to start a new one."
137
- }
138
- },
139
- required: ["ask"]
140
- };
141
- 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 } \u2014 pass notificationId to await_reply for the answer, 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. 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 2-4 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.`;
142
- 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.";
143
- var SERVER_INSTRUCTIONS = "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. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without 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` (2-4 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.";
144
-
145
- // src/pairing.ts
146
- async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
147
- let kf = readKeyFile();
148
- const start = Date.now();
149
- while (Date.now() - start < capMs) {
150
- let step;
151
- try {
152
- step = await pairStep(deviceCode, kf);
153
- } catch (e) {
154
- return { kind: "error", message: e.message };
155
- }
156
- if (step.kind === "e2ee_aborted") {
157
- deleteKeyFile();
158
- kf = null;
159
- await sleep(pollMs);
160
- continue;
161
- }
162
- if (step.kind === "awaiting_confirm") {
163
- return { kind: "awaiting_confirm", sas: step.sas };
164
- }
165
- if (step.kind === "paired") {
166
- saveToken(step.token);
167
- if (!step.sas || !kf) {
168
- deleteKeyFile();
169
- return { kind: "paired", token: step.token };
170
- }
171
- const finalDeadline = Math.min(Date.now() + 1e4, start + capMs);
172
- let e2ee = false;
173
- while (Date.now() < finalDeadline) {
174
- const cred = kf.userCode ? await fetchCredential(kf.userCode) : null;
175
- if (cred && step.uikPub) {
176
- e2ee = finalizeE2ee(kf, cred, step.uikPub);
177
- break;
178
- }
179
- await sleep(pollMs);
180
- }
181
- if (!e2ee) deleteKeyFile();
182
- return { kind: "paired", token: step.token, sas: step.sas, e2ee };
183
- }
184
- await sleep(pollMs);
185
- }
186
- return { kind: "pending" };
187
- }
188
- var bg = null;
189
- var started = null;
190
- async function startPairing(agent) {
191
- if (started) return started;
192
- const { keyFile, offer } = startE2ee();
193
- const code = await requestCode(agent, offer);
194
- started = {
195
- verificationUri: code.verification_uri_complete,
196
- userCode: code.user_code,
197
- deviceCode: code.device_code,
198
- expiresIn: code.expires_in
199
- };
200
- saveKeyFile({ ...keyFile, userCode: code.user_code });
201
- startBackgroundPair(code.device_code, code.expires_in * 1e3);
202
- return started;
203
- }
204
- function startBackgroundPair(deviceCode, budgetMs) {
205
- const promise = resolvePairing(deviceCode, budgetMs).catch((e) => ({ kind: "error", message: e.message })).then((o) => {
206
- if (bg?.deviceCode === deviceCode) bg.settled = o;
207
- return o;
208
- });
209
- bg = { deviceCode, promise };
210
- }
211
- async function joinBackgroundPair(deviceCode, capMs) {
212
- if (!bg || bg.deviceCode !== deviceCode) return null;
213
- if (bg.settled) {
214
- const s = bg.settled;
215
- bg = null;
216
- if (s.kind === "paired" || s.kind === "error") started = null;
217
- return s;
218
- }
219
- const TIMEOUT = /* @__PURE__ */ Symbol("timeout");
220
- const raced = await Promise.race([bg.promise, sleep(capMs).then(() => TIMEOUT)]);
221
- if (raced !== TIMEOUT) {
222
- bg = null;
223
- if (raced.kind === "paired" || raced.kind === "error") started = null;
224
- return raced;
225
- }
226
- if (bg?.settled) {
227
- const s = bg.settled;
228
- bg = null;
229
- if (s.kind === "paired" || s.kind === "error") started = null;
230
- return s;
82
+ var JOIN_CAP_MS = 45e3;
83
+ function fixAndRetry(err) {
84
+ const msg = String(err?.message ?? err);
85
+ const m = msg.match(/^notify failed: 422 (\{[\s\S]*\})$/);
86
+ if (!m) return null;
87
+ try {
88
+ const body = JSON.parse(m[1]);
89
+ return body.error === "fix_and_retry" ? { error: "fix_and_retry", problems: body.problems ?? [] } : null;
90
+ } catch {
91
+ return null;
231
92
  }
232
- return { kind: "pending" };
233
- }
234
- function cancelBackgroundPair() {
235
- bg = null;
236
- started = null;
237
93
  }
238
- function clearPairing(deviceCode) {
239
- if (deviceCode && started?.deviceCode !== deviceCode) return;
240
- cancelBackgroundPair();
241
- }
242
-
243
- // src/index.ts
244
- var AwaitReplySchema = z.object({
245
- notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only."),
246
- maxWaitSeconds: z.number().int().min(5).max(300).optional().describe(
247
- "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."
248
- )
249
- });
250
- var JOIN_CAP_MS = 45e3;
251
- var OnboardSchema = z.object({
252
- name: z.string().max(60).optional(),
253
- voice: z.string().max(40).optional(),
254
- /** Continue a code ceremony already in flight — same meaning as `pair`'s. */
255
- device_code: z.string().optional(),
256
- /** Which allowlist to REPORT on (never written by `start`). */
257
- scope: z.enum(["user", "project"]).optional()
258
- });
259
- var PairSchema = z.object({
260
- 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."),
261
- name: z.string().min(1).max(60).optional().describe("Hatch path only: the name you choose for this identity. Pick your own \u2014 ONE or TWO words, the way you'd introduce yourself on a call (it is spoken aloud and shown in lists). 'Piper', 'Blue Heron' \u2014 never a sentence or a task description."),
262
- voice: z.string().optional().describe("Hatch path only: your voice on calls \u2014 one of rachel, george, jessica, brian, lily.")
263
- });
264
- var GetThreadSchema = z.object({
265
- parentId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
266
- });
267
- var SearchThreadsSchema = z.object({
268
- q: z.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
269
- });
270
- var SetTaskStateToolSchema = z.object({
271
- notificationId: z.string(),
272
- state: SetTaskStateSchema.shape.state
273
- });
274
- var AnswerCallerQuestionSchema = z.object({
275
- notificationId: z.string().describe("The notification whose call carried the caller's question \u2014 from the partial turn or the settled reply."),
276
- 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.")
277
- });
278
94
  function detectGit() {
279
95
  const run = (cmd) => {
280
96
  try {
@@ -370,25 +186,42 @@ function unidentifiedResult() {
370
186
  message: `This session has no Paigy identity yet, so nothing was sent \u2014 it will not speak as another session. This machine holds a device credential but hatching under it just failed (it may have been revoked), so call \`pair\` (no arguments) to set this session up, then retry \u2014 it re-hatches if the credential recovered and runs the code ceremony if it didn't. To reuse an existing identity instead, start the session with PAIGY_AGENT set to its slot (${listSlots().filter((s) => s !== "Desktop").join(", ") || "none yet"}).`
371
187
  }) }] };
372
188
  }
373
- function pairStartResult(start) {
189
+ function pairStartResult(start, loggedOut) {
374
190
  writeSurface("pairing code", start.userCode, start.expiresIn);
375
191
  const qr = qrcode(0, "M");
376
192
  qr.addData(start.verificationUri);
377
193
  qr.make();
194
+ const link = start.verificationUri;
195
+ const ascii = qr.createASCII(1, 2);
196
+ const userMessage = loggedOut ? `**Your machine was logged out.** Reconnect and every agent here pairs itself again \u2014 no more codes.
197
+
198
+ **[Reconnect this machine](${link})**
199
+
200
+ Or open: ${link}
201
+
202
+ Or scan:
203
+
204
+ \`\`\`
205
+ ${ascii}
206
+ \`\`\`
207
+
208
+ Or enter code **${start.userCode}** in the Paigy app (Inbox \u2192 Add a new agent).` : `**[Approve this agent](${link})**
209
+
210
+ Or open: ${link}
211
+ Or enter code **${start.userCode}** in the Paigy app (Inbox \u2192 Add a new agent).`;
378
212
  return { content: [
379
- { type: "text", text: `PAIRING CODE: ${start.userCode}
380
- Enter it in the Paigy app: Inbox \u2192 Add a new agent.` },
213
+ { type: "text", text: loggedOut ? `YOUR MACHINE WAS LOGGED OUT \u2014 reconnect: ${link}
214
+ (or scan the QR, or enter code ${start.userCode} in the app)` : `APPROVE: ${link}
215
+ (or code ${start.userCode} in the app: Inbox \u2192 Add a new agent)` },
381
216
  { type: "text", text: JSON.stringify({
382
217
  status: "awaiting_approval",
383
- verification_uri_complete: start.verificationUri,
218
+ verification_uri_complete: link,
384
219
  user_code: start.userCode,
385
220
  device_code: start.deviceCode,
386
221
  expires_in: start.expiresIn,
387
- qr: qr.createASCII(1, 2),
388
- user_message: `# ${start.userCode}
389
-
390
- Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
391
- message: "REQUIRED: print user_message for the user, then call pair again with device_code to collect approval. Do not open a browser."
222
+ qr: ascii,
223
+ user_message: userMessage,
224
+ message: "REQUIRED: print user_message for the user \u2014 the LINK is the primary action, the code is the fallback, and `qr` is there for a phone when they ask. Then call pair again with device_code to collect approval. Do not open a browser yourself."
392
225
  }) }
393
226
  ] };
394
227
  }
@@ -403,107 +236,40 @@ function prependNote(result, note) {
403
236
  return { content: [{ type: "text", text: note }, ...result.content] };
404
237
  }
405
238
  async function hatchUnderDevice(name, voice) {
406
- if (!listSlots().includes("Desktop")) return null;
407
- overrideToken2(readToken("Desktop"));
239
+ if (!listSlots().includes("Desktop")) return { why: "no-credential" };
240
+ overrideToken(readToken("Desktop"));
408
241
  try {
409
- const minted = await hatch(name ?? suggestedAgentName() ?? "Agent", voice ?? null);
242
+ const minted = await hatch(name ?? suggestedAgentName2() ?? "Agent", voice ?? null);
410
243
  const dt = { ok: true, access_token: minted.token, name: minted.name, device: null };
411
244
  saveToken(dt);
412
245
  return dt;
413
- } catch {
414
- return null;
246
+ } catch (e) {
247
+ return { why: "credential-rejected", detail: String(e?.message ?? e).slice(0, 200) };
415
248
  } finally {
416
- overrideToken2(null);
249
+ overrideToken(null);
417
250
  }
418
251
  }
419
252
  async function runPair(device_code, name, voice, note) {
253
+ let miss = null;
420
254
  if (!device_code) {
421
255
  const dt = await hatchUnderDevice(name, voice);
422
- if (dt) return pairedResult(
256
+ if ("ok" in dt) return pairedResult(
423
257
  dt,
424
258
  void 0,
425
259
  "Hatched instantly under this device's credential \u2014 no code needed. " + (name ? "" : `You were given a default name ("${dt.name}") \u2014 the user never chose it, so don't announce it as their agent's identity. They can rename it in the app, or you can re-call pair with { name, voice }.`)
426
260
  );
261
+ miss = dt;
427
262
  }
428
263
  if (!device_code) {
429
- const started2 = pairStartResult(await startPairing(suggestedAgentName()));
430
- return note ? prependNote(started2, note) : started2;
264
+ const loggedOut = miss?.why === "credential-rejected" ? miss.detail : void 0;
265
+ const started = pairStartResult(await startPairing(suggestedAgentName2()), loggedOut);
266
+ return note ? prependNote(started, note) : started;
431
267
  }
432
268
  const capMs = JOIN_CAP_MS;
433
269
  const outcome = await joinBackgroundPair(device_code, capMs) ?? await resolvePairing(device_code, capMs);
434
270
  return renderPairOutcome(outcome, device_code);
435
271
  }
436
- server.setRequestHandler(ListToolsRequestSchema, async () => {
437
- const tools = [
438
- {
439
- // #575: THE attention verb — the one model-facing surface, every agent.
440
- // The retired names (notify/notify_user) stay callable as hidden aliases
441
- // for stale prompts and cached servers; see toolset.ts for the design.
442
- name: "contact",
443
- description: CONTACT_DESCRIPTION,
444
- inputSchema: CONTACT_SCHEMA
445
- },
446
- {
447
- // ONE rail for starting a session (#875). `pair`, the `paigy-mcp-onboard` CLI and
448
- // `enable_tools` were three doors into one flow, and "onboard" was the word people
449
- // reached for attached to the tool that did the least. It's the door now.
450
- name: "onboard",
451
- description: ONBOARD_DESCRIPTION,
452
- inputSchema: json(OnboardSchema)
453
- },
454
- {
455
- name: "pair",
456
- 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.",
457
- inputSchema: json(PairSchema)
458
- },
459
- {
460
- name: "unpair",
461
- 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.",
462
- inputSchema: json(z.object({}))
463
- },
464
- {
465
- name: "await_reply",
466
- 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 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.",
467
- inputSchema: json(AwaitReplySchema)
468
- },
469
- {
470
- name: "check_replies",
471
- description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, parentId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. 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_task_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_task_state on that thread as you would for any assigned work. Ignoring it costs nothing \u2014 it rides your next reply too. Also returns `stalled`: work (either direction) you reported in_progress via set_task_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. 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_task_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.",
472
- inputSchema: json(z.object({}))
473
- },
474
- {
475
- name: "get_thread",
476
- description: "The chronological transcript of one Paigy conversation thread \u2014 every past ask, answer, and user request on it. Call this to REHYDRATE when you're resuming or being seeded: a check_replies request whose parentId you don't recognize means the user is continuing an old conversation with you, and one carrying a contextParentId means they want a past conversation (possibly with a DIFFERENT agent) as your starting context \u2014 in both cases call get_thread FIRST and read the turns as prior conversation you were part of, not as new input. Turns: { role:'agent', title, description[], answer }, { role:'user', text }, and context turns { role:'handoff'|'recap', title, description[] } \u2014 a handoff is a predecessor's brief for you; a recap SUMMARIZES everything before it (the transcript starts at the latest recap, so treat it as the base and the turns after it as what happened since). Oldest first, capped at the most recent 30.",
477
- inputSchema: json(GetThreadSchema)
478
- },
479
- {
480
- name: "search_threads",
481
- 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.`,
482
- inputSchema: json(SearchThreadsSchema)
483
- },
484
- {
485
- name: "set_task_state",
486
- 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 clarifies = 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.",
487
- inputSchema: json(SetTaskStateToolSchema)
488
- },
489
- {
490
- name: "answer_caller_question",
491
- 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.",
492
- inputSchema: json(AnswerCallerQuestionSchema)
493
- },
494
- {
495
- name: "schedule_callback",
496
- 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 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.",
497
- inputSchema: json(ScheduleCallbackSchema)
498
- },
499
- {
500
- name: "handoff",
501
- description: "Deposit your working context for a SUCCESSOR agent \u2014 what you did, what's left, links, gotchas \u2014 as one note on a thread ({ title, notes[] }). This does NOT ring the user or enter their inbox: it's context, not a question. The successor reads it back with get_thread. Pass `target` (a sibling connection's token id or agent name, SAME account only) to hand off DIRECTLY to that agent \u2014 the note is dispatched to it as a request it picks up. Omit `target` to leave the thread for the user to hand off to an agent themselves in the app. Pass `parentId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { parentId }. Pass recap:true when the note SUMMARIZES the thread so far (for a successor OR for your own later session): a recap resets the rehydration window \u2014 get_thread returns the latest recap + only the turns after it. Write one whenever a thread has grown long and you're pausing, handing off, or nearing your context limit.",
502
- inputSchema: json(HandoffSchema)
503
- }
504
- ];
505
- return { tools };
506
- });
272
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
507
273
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
508
274
  try {
509
275
  return await handleTool(request, extra?.signal);
@@ -513,29 +279,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
513
279
  const hatched = IDENTITY_TOOLS.has(request.params.name) ? null : await hatchUnderDevice();
514
280
  if (hatched) return await handleTool(request, extra?.signal);
515
281
  if (listSlots().includes("Desktop")) return unidentifiedResult();
516
- return pairStartResult(await startPairing(suggestedAgentName()));
282
+ return pairStartResult(await startPairing(suggestedAgentName2()));
517
283
  }
518
284
  throw e;
519
285
  }
520
286
  });
521
- var CLIENT_LABELS = {
522
- "claude-code": "Claude Code",
523
- "claude-ai": "Claude",
524
- cursor: "Cursor",
525
- "cursor-vscode": "Cursor",
526
- windsurf: "Windsurf",
527
- cline: "Cline",
528
- "roo-cline": "Roo Code",
529
- continue: "Continue",
530
- vscode: "VS Code",
531
- "visual studio code": "VS Code",
532
- zed: "Zed"
533
- };
534
- function suggestedAgentName() {
535
- if (process.env.PAIGY_AGENT) return process.env.PAIGY_AGENT;
536
- const raw = server.getClientVersion?.()?.name;
537
- if (!raw) return void 0;
538
- return CLIENT_LABELS[raw.toLowerCase()] ?? raw.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
287
+ function suggestedAgentName2() {
288
+ return suggestedAgentName(server.getClientVersion?.()?.name);
539
289
  }
540
290
  async function handleTool(request, signal) {
541
291
  switch (request.params.name) {
@@ -631,34 +381,41 @@ async function handleTool(request, signal) {
631
381
  case "contact":
632
382
  case "notify":
633
383
  case "notify_user": {
634
- const parsed = normalizeSpeech(NotifyRequestSchema.parse(request.params.arguments));
635
- const problems = lintNotify(parsed);
636
- if (problems.length) {
637
- return {
638
- isError: true,
639
- content: [{ type: "text", text: JSON.stringify({ error: "fix_and_retry", problems }) }]
640
- };
641
- }
384
+ const parsed = NotifyRequestSchema.parse(request.params.arguments);
642
385
  const git = detectGit();
643
386
  const enriched = {
644
387
  ...parsed,
645
388
  repo: parsed.repo ?? git.repo,
646
- branch: parsed.branch ?? git.branch
389
+ branch: parsed.branch ?? git.branch,
390
+ // THREAD-SCOPED IDENTITY (stage 3, identity-ownership-design.md). A worker subagent
391
+ // shares the parent's identity/slot, so its requests would otherwise scatter across
392
+ // fresh threads and mix with a sibling's. Default an unthreaded contact to THIS
393
+ // subagent's own thread so its requests group and siblings never collide. An explicit
394
+ // parentId (continuing a specific conversation) always wins; a top-level parent session
395
+ // is left untouched (undefined ⇒ the server mints a fresh thread, exactly as today).
396
+ parentId: contactParentId(parsed.parentId)
647
397
  };
648
- const result = await submitNotification(enriched);
398
+ let result;
399
+ try {
400
+ result = await submitNotification(enriched);
401
+ } catch (err) {
402
+ const fixable = fixAndRetry(err);
403
+ if (fixable) return { isError: true, content: [{ type: "text", text: JSON.stringify(fixable) }] };
404
+ throw err;
405
+ }
649
406
  const sentAs = slotName(AGENT_NAME);
650
- return { content: [{ type: "text", text: JSON.stringify(sentAs ? { ...result, sentAs } : result) }] };
407
+ const shown = { ...result, message: contactMessage(result) };
408
+ return { content: [{ type: "text", text: JSON.stringify(sentAs ? { ...shown, sentAs } : shown) }] };
651
409
  }
652
410
  case "await_reply": {
653
- const { notificationId, maxWaitSeconds } = AwaitReplySchema.parse(request.params.arguments);
654
- const item = await awaitReply(notificationId, {
655
- ...maxWaitSeconds !== void 0 ? { windowMs: maxWaitSeconds * 1e3 } : {},
411
+ const { notificationId } = AwaitReplySchema.parse(request.params.arguments);
412
+ const item = await awaitReply2(notificationId, {
656
413
  ...signal ? { signal } : {}
657
414
  });
658
- return { content: [{ type: "text", text: JSON.stringify(item) }] };
415
+ return { content: [{ type: "text", text: JSON.stringify({ ...item, message: awaitMessage(item) }) }] };
659
416
  }
660
417
  case "check_replies": {
661
- const result = await checkReplies2();
418
+ const result = await checkReplies();
662
419
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
663
420
  }
664
421
  case "get_thread": {
@@ -682,6 +439,22 @@ async function handleTool(request, signal) {
682
439
  const result = await setTaskState(notificationId, state);
683
440
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
684
441
  }
442
+ case "set_work_state": {
443
+ const result = await setWorkState(SetWorkStateSchema.parse(request.params.arguments));
444
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
445
+ }
446
+ case "create_goal": {
447
+ const result = await createGoal(CreateGoalSchema.parse(request.params.arguments));
448
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
449
+ }
450
+ case "claim_goal": {
451
+ const { goalId } = ClaimGoalSchema.parse(request.params.arguments);
452
+ return { content: [{ type: "text", text: JSON.stringify(await claimGoal(goalId)) }] };
453
+ }
454
+ case "update_goal": {
455
+ const { goalId, ...input } = UpdateGoalToolSchema.parse(request.params.arguments);
456
+ return { content: [{ type: "text", text: JSON.stringify(await updateGoal(goalId, input)) }] };
457
+ }
685
458
  case "schedule_callback": {
686
459
  const result = await scheduleCallback(ScheduleCallbackSchema.parse(request.params.arguments));
687
460
  return { content: [{ type: "text", text: JSON.stringify(result) }] };