@paigy/mcp 0.17.0 → 0.18.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.
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  AGENT_NAME
3
- } from "./chunk-4LQAS5ZB.js";
3
+ } from "./chunk-RMTTO6BI.js";
4
4
 
5
5
  // src/clients.ts
6
6
  import { execFile } from "child_process";
7
- import { existsSync, readFileSync, writeFileSync } from "fs";
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
8
  import { homedir, platform } from "os";
9
9
  import { join, dirname } from "path";
10
10
  function openBrowser(url) {
@@ -81,9 +81,73 @@ function claudeInstallHint(skip = AGENT_NAME) {
81
81
  if (!existsSync(join(homedir(), ".claude"))) return null;
82
82
  return "Claude Code detected \u2014 to add Paigy there, run /plugin marketplace add paigy-ai/claude then /plugin install paigy (pairing carries over; no need to pair again).";
83
83
  }
84
+ var PAIGY_TOOL_IDS = [
85
+ "mcp__paigy__notify_user",
86
+ "mcp__paigy__notify",
87
+ "mcp__paigy__await_reply",
88
+ "mcp__paigy__check_replies",
89
+ "mcp__paigy__set_task_state",
90
+ "mcp__paigy__schedule_callback",
91
+ "mcp__paigy__get_thread"
92
+ ];
93
+ function withPaigyAllowlist(existing, tools) {
94
+ let config = {};
95
+ if (existing) {
96
+ try {
97
+ config = JSON.parse(existing);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ const permissions = config.permissions && typeof config.permissions === "object" ? config.permissions : {};
103
+ const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
104
+ for (const t of tools) if (!allow.includes(t)) allow.push(t);
105
+ config.permissions = { ...permissions, allow };
106
+ return JSON.stringify(config, null, 2);
107
+ }
108
+ function enablePaigyTools(scope = "user", cwd = process.cwd()) {
109
+ if (scope === "user" && !existsSync(join(homedir(), ".claude"))) {
110
+ return {
111
+ ok: false,
112
+ reason: `No ~/.claude on this machine \u2014 the hosting tool uses its own permission allowlist. Add these ids there by hand: ${PAIGY_TOOL_IDS.join(", ")}.`
113
+ };
114
+ }
115
+ const path = scope === "project" ? join(cwd, ".claude", "settings.json") : join(homedir(), ".claude", "settings.json");
116
+ let existing = null;
117
+ try {
118
+ existing = existsSync(path) ? readFileSync(path, "utf8") : null;
119
+ } catch {
120
+ existing = null;
121
+ }
122
+ let prevAllow = [];
123
+ if (existing) {
124
+ try {
125
+ const parsed = JSON.parse(existing);
126
+ if (Array.isArray(parsed.permissions?.allow)) prevAllow = parsed.permissions.allow;
127
+ } catch {
128
+ return { ok: false, reason: `${path} didn't parse as JSON \u2014 not overwriting it. Add the ids by hand.` };
129
+ }
130
+ }
131
+ const updated = withPaigyAllowlist(existing, PAIGY_TOOL_IDS);
132
+ if (updated === null) return { ok: false, reason: `${path} didn't parse as JSON \u2014 not overwriting it. Add the ids by hand.` };
133
+ try {
134
+ mkdirSync(dirname(path), { recursive: true });
135
+ writeFileSync(path, updated + "\n");
136
+ } catch (e) {
137
+ return { ok: false, reason: `Couldn't write ${path}: ${e.message}` };
138
+ }
139
+ return {
140
+ ok: true,
141
+ path,
142
+ added: PAIGY_TOOL_IDS.filter((t) => !prevAllow.includes(t)),
143
+ already: PAIGY_TOOL_IDS.filter((t) => prevAllow.includes(t))
144
+ };
145
+ }
84
146
 
85
147
  export {
86
148
  openBrowser,
87
149
  autoConfigureClients,
88
- claudeInstallHint
150
+ claudeInstallHint,
151
+ PAIGY_TOOL_IDS,
152
+ enablePaigyTools
89
153
  };
@@ -2278,6 +2278,26 @@ var ContextSchema = z.object({
2278
2278
  title: z.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
2279
2279
  description: z.array(z.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
2280
2280
  });
2281
+ var ParticipantSchema = z.object({
2282
+ kind: z.enum(["human", "agent"]),
2283
+ id: z.string()
2284
+ });
2285
+ var TransformSchema = z.enum([
2286
+ "structure",
2287
+ // shape intent into an answer contract; pick channel/urgency — broker `ask`, `select` shapes, `points`
2288
+ "request_more",
2289
+ // clarify / follow-ups / uncovered points; escalate inbox→call — {kind:'clarify'}, escalate, blocking
2290
+ "redirect",
2291
+ // seed / hand off a thread to a new recipient — handoff, "new session from this"
2292
+ "break_down",
2293
+ // one bundle → many sub-asks — checklist fan-out, `points`
2294
+ "coalesce",
2295
+ // many bundles → one — morning triage (#347), threading-supersede, digest
2296
+ "organize",
2297
+ // group related bundles onto one thread — threading (`threadId`), parent/clarify links
2298
+ "summarize"
2299
+ // reduce volume, keep decision value — 30-turn cap, spoken briefing
2300
+ ]);
2281
2301
  var OptionSchema = z.object({
2282
2302
  id: z.string(),
2283
2303
  label: z.string(),
@@ -2295,6 +2315,37 @@ var VisualSchema = z.object({
2295
2315
  label: z.string().optional()
2296
2316
  });
2297
2317
  var NotifyLevelSchema = z.enum(["inbox", "push", "banner", "call"]);
2318
+ var SelectShapeSchema = z.enum(["one", "many", "rank", "confirm", "text"]);
2319
+ var ReceiptEventSchema = z.enum([
2320
+ "delivered",
2321
+ // the bundle reached the recipient at some level
2322
+ "seen",
2323
+ // the recipient opened it
2324
+ "answered",
2325
+ // the recipient replied
2326
+ "escalated",
2327
+ // re-reached at a higher level (re-ring / promote)
2328
+ "coalesced",
2329
+ // merged into another live claim
2330
+ "expired",
2331
+ // deadline passed unanswered
2332
+ "woke",
2333
+ // the agent was woken for an owed obligation (callback)
2334
+ "gave_up"
2335
+ // the budget was spent — stopped re-engaging
2336
+ ]);
2337
+ var AttentionSchema = z.object({
2338
+ urgency: NotifyLevelSchema,
2339
+ /** The required answer shape, or null for a plain notify that asks nothing back. */
2340
+ select: SelectShapeSchema.nullable(),
2341
+ /** Coverage contract (#396) — points the answer must address; null = none declared. */
2342
+ points: z.array(z.string()).nullable(),
2343
+ /** Whether the ask blocks the sender — what lets arbitration escalate it on silence. */
2344
+ blocking: z.boolean(),
2345
+ /** Reserved (MODEL.md lists it): a response deadline. No row column yet — a later Phase 2
2346
+ * slice wires it; optional so today's rows/callers project cleanly. */
2347
+ deadline: z.string().datetime().nullable().optional()
2348
+ });
2298
2349
  var NotifyRequestSchema = z.object({
2299
2350
  /** Plaintext message content. Present on the plaintext path (today's shape);
2300
2351
  * ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
@@ -2337,7 +2388,7 @@ var NotifyRequestSchema = z.object({
2337
2388
  options: z.lazy(() => EnvelopeSchema).optional(),
2338
2389
  visuals: z.lazy(() => EnvelopeSchema).optional()
2339
2390
  }).optional(),
2340
- select: z.enum(["one", "many", "rank", "confirm", "text"]).optional().describe(
2391
+ select: SelectShapeSchema.optional().describe(
2341
2392
  "How the user answers \u2014 required on the fully-shaped form, pick the shape that fits the question: 'one' = pick one option, 'many' = pick several, 'rank' = pick & order (each needs `options`); 'confirm' = yes/no or approve/deny; 'text' = free-form reply only (status updates, open questions). 'confirm' and 'text' take no options. Omit only when sending the simplified `ask` form \u2014 the broker picks the shape."
2342
2393
  ),
2343
2394
  /** The simplified form (#395): instead of shaping the notification yourself
@@ -2713,6 +2764,17 @@ var CreateRequestSchema = z.object({
2713
2764
  * the agent reads it via get_thread. Must belong to the requesting user. */
2714
2765
  contextThreadId: z.string().optional()
2715
2766
  });
2767
+ var HandoffSchema = z.object({
2768
+ /** Land the note on an existing thread; omitted mints a fresh one. */
2769
+ threadId: z.string().uuid().optional(),
2770
+ /** One-line headline of the working context handed off. */
2771
+ title: z.string().min(1),
2772
+ /** The brief — standalone notes the successor reads (what was done, what's left, links). */
2773
+ notes: z.array(z.string().min(1)).min(1),
2774
+ /** A sibling connection to dispatch directly to (token id or agent nickname). Same-account
2775
+ * only; omit to leave the thread for the user to hand off in the app. */
2776
+ target: z.string().optional()
2777
+ });
2716
2778
  var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
2717
2779
  var RegisterDeliverySchema = z.object({ mode: DeliveryModeSchema });
2718
2780
  var OAuthStartSchema = z.object({
@@ -3502,6 +3564,16 @@ async function scheduleCallback(req) {
3502
3564
  if (!res.ok) throw new Error(`schedule_callback failed: ${res.status} ${await res.text()}`);
3503
3565
  return await res.json();
3504
3566
  }
3567
+ async function handoff(req) {
3568
+ const token = readToken();
3569
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/handoff`, {
3570
+ method: "POST",
3571
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
3572
+ body: JSON.stringify(req)
3573
+ }));
3574
+ if (!res.ok) throw new Error(`handoff failed: ${res.status} ${await res.text()}`);
3575
+ return await res.json();
3576
+ }
3505
3577
  var TITLE_MAX = 90;
3506
3578
  var CHUNKS_MAX = 8;
3507
3579
  var CHUNK_MAX = 300;
@@ -3575,5 +3647,6 @@ export {
3575
3647
  setTaskState,
3576
3648
  registerDelivery,
3577
3649
  scheduleCallback,
3650
+ handoff,
3578
3651
  lintNotify
3579
3652
  };
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ HandoffSchema
4
+ } from "./chunk-DSWZOBK5.js";
5
+ import {
6
+ PAIGY_TOOL_IDS,
3
7
  autoConfigureClients,
4
- claudeInstallHint
5
- } from "./chunk-ZIOTFQPK.js";
8
+ claudeInstallHint,
9
+ enablePaigyTools
10
+ } from "./chunk-HAG2VWKB.js";
6
11
  import {
7
12
  clearSurface,
8
13
  writeSurface
@@ -19,6 +24,7 @@ import {
19
24
  fetchCredential,
20
25
  finalizeE2ee,
21
26
  getThread,
27
+ handoff,
22
28
  lintNotify,
23
29
  pairStep,
24
30
  readKeyFile,
@@ -32,7 +38,7 @@ import {
32
38
  sleep,
33
39
  startE2ee,
34
40
  submitNotification
35
- } from "./chunk-4LQAS5ZB.js";
41
+ } from "./chunk-RMTTO6BI.js";
36
42
 
37
43
  // src/index.ts
38
44
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -70,6 +76,81 @@ function json(s) {
70
76
  return draft2020(schema);
71
77
  }
72
78
 
79
+ // src/pairing.ts
80
+ async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
81
+ let kf = readKeyFile();
82
+ const start = Date.now();
83
+ while (Date.now() - start < capMs) {
84
+ let step;
85
+ try {
86
+ step = await pairStep(deviceCode, kf);
87
+ } catch (e) {
88
+ return { kind: "error", message: e.message };
89
+ }
90
+ if (step.kind === "e2ee_aborted") {
91
+ deleteKeyFile();
92
+ kf = null;
93
+ await sleep(pollMs);
94
+ continue;
95
+ }
96
+ if (step.kind === "awaiting_confirm") {
97
+ return { kind: "awaiting_confirm", sas: step.sas };
98
+ }
99
+ if (step.kind === "paired") {
100
+ saveToken(step.token);
101
+ if (!step.sas || !kf) {
102
+ deleteKeyFile();
103
+ return { kind: "paired", token: step.token };
104
+ }
105
+ const finalDeadline = Math.min(Date.now() + 1e4, start + capMs);
106
+ let e2ee = false;
107
+ while (Date.now() < finalDeadline) {
108
+ const cred = kf.userCode ? await fetchCredential(kf.userCode) : null;
109
+ if (cred && step.uikPub) {
110
+ e2ee = finalizeE2ee(kf, cred, step.uikPub);
111
+ break;
112
+ }
113
+ await sleep(pollMs);
114
+ }
115
+ if (!e2ee) deleteKeyFile();
116
+ return { kind: "paired", token: step.token, sas: step.sas, e2ee };
117
+ }
118
+ await sleep(pollMs);
119
+ }
120
+ return { kind: "pending" };
121
+ }
122
+ var bg = null;
123
+ function startBackgroundPair(deviceCode, budgetMs) {
124
+ const promise = resolvePairing(deviceCode, budgetMs).catch((e) => ({ kind: "error", message: e.message })).then((o) => {
125
+ if (bg?.deviceCode === deviceCode) bg.settled = o;
126
+ return o;
127
+ });
128
+ bg = { deviceCode, promise };
129
+ }
130
+ async function joinBackgroundPair(deviceCode, capMs) {
131
+ if (!bg || bg.deviceCode !== deviceCode) return null;
132
+ if (bg.settled) {
133
+ const s = bg.settled;
134
+ bg = null;
135
+ return s;
136
+ }
137
+ const TIMEOUT = /* @__PURE__ */ Symbol("timeout");
138
+ const raced = await Promise.race([bg.promise, sleep(capMs).then(() => TIMEOUT)]);
139
+ if (raced !== TIMEOUT) {
140
+ bg = null;
141
+ return raced;
142
+ }
143
+ if (bg?.settled) {
144
+ const s = bg.settled;
145
+ bg = null;
146
+ return s;
147
+ }
148
+ return { kind: "pending" };
149
+ }
150
+ function cancelBackgroundPair() {
151
+ bg = null;
152
+ }
153
+
73
154
  // src/index.ts
74
155
  var ONBOARD_MSG = "Not paired with Paigy yet \u2014 call the `pair` tool to connect this agent (it returns an approval link to show the user), then retry. Manual fallback: `npx -y -p @paigy/mcp paigy-mcp-onboard`.";
75
156
  var AwaitReplySchema = z.object({
@@ -81,6 +162,9 @@ var PairSchema = z.object({
81
162
  var GetThreadSchema = z.object({
82
163
  threadId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
83
164
  });
165
+ var EnableToolsSchema = z.object({
166
+ scope: z.enum(["user", "project"]).optional().describe("'user' (default) allowlists Paigy's tools for every project (~/.claude/settings.json); 'project' scopes them to this repo (.claude/settings.json).")
167
+ });
84
168
  var SetTaskStateToolSchema = z.object({
85
169
  notificationId: z.string(),
86
170
  state: SetTaskStateSchema.shape.state
@@ -125,7 +209,9 @@ function pairedResult(token, sas, note, e2ee) {
125
209
  if (hint) base.claude_hint = hint;
126
210
  } catch {
127
211
  }
128
- base.permissions_hint = "Offer the user ONCE: allowlist Paigy's tools so they run without prompting (in Claude Code, add mcp__paigy__notify_user, mcp__paigy__await_reply, mcp__paigy__check_replies, mcp__paigy__set_task_state, mcp__paigy__schedule_callback, mcp__paigy__get_thread to permissions.allow in ~/.claude/settings.json \u2014 or the project's .claude/settings.json if they prefer; other tools have their own allowlist). Merge into the existing file, never clobber it. Leave pair/unpair human-approved. If they decline, drop it.";
212
+ base.enable_tool_ids = PAIGY_TOOL_IDS;
213
+ base.enable_prompt = `REQUIRED \u2014 before you do anything else, ask the user now: "Want me to let Paigy's tools run without an approval prompt each time? I'll only allowlist the notify/await tools \u2014 pair and unpair still ask." Prompt every time you pair; don't skip it.`;
214
+ base.enable_apply = "On yes: call the `enable_tools` tool \u2014 it writes the allowlist for you (scope:'user' = every project, the default; scope:'project' = just this repo). No hand-editing. If it returns ok:false (the host isn't Claude Code), fall back to adding `enable_tool_ids` to that tool's own allowlist by hand, merging \u2014 never clobbering. pair/unpair stay out, so they remain human-approved. On no: drop it, don't ask again this session.";
129
215
  return { content: [{ type: "text", text: JSON.stringify(base) }] };
130
216
  }
131
217
  function awaitingConfirmResult(sas, device_code) {
@@ -149,6 +235,28 @@ Check it matches the code on the phone, then type it there to confirm.` },
149
235
  ]
150
236
  };
151
237
  }
238
+ function renderPairOutcome(outcome, device_code) {
239
+ switch (outcome.kind) {
240
+ case "error":
241
+ clearSurface();
242
+ throw new Error(outcome.message);
243
+ case "awaiting_confirm":
244
+ return awaitingConfirmResult(outcome.sas, device_code);
245
+ case "paired":
246
+ return pairedResult(outcome.token, outcome.sas, void 0, outcome.e2ee);
247
+ case "pending":
248
+ return {
249
+ content: [{
250
+ type: "text",
251
+ text: JSON.stringify({
252
+ status: "pending",
253
+ device_code,
254
+ message: "Still awaiting approval. Call pair again with this device_code to keep waiting."
255
+ })
256
+ }]
257
+ };
258
+ }
259
+ }
152
260
  var server = new Server(
153
261
  { name: "paigy", version: "0.0.0" },
154
262
  {
@@ -160,7 +268,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
160
268
  tools: [
161
269
  {
162
270
  name: "pair",
163
- description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before notify_user/await_reply work. 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 }. Then END YOUR TURN immediately, replying with `user_message` (the bare code) as your ENTIRE message \u2014 do NOT chain the step-2 call in the same turn, or the code text is dropped before the user ever sees it (this is the #1 pairing bug). Step 2 (a SEPARATE later turn, after the user says they've entered it): call with that device_code to poll for approval; on { status:'pending' } call again to keep waiting; on { status:'awaiting_confirmation' } (E2EE) show the bare `user_message` verify code and end the turn again, polling on the next. The leading text block of every result already states the code plainly, so it shows even if you emit no prose.",
271
+ description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before notify_user/await_reply work. 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. SHOW the user `user_message` (the bare code) so it isn't buried in prose, THEN call step 2 \u2014 you can do both in the same turn (emit the code text first, then call pair). 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 ask the user to allowlist Paigy's tools so notify/await don't prompt each time.",
164
272
  inputSchema: json(PairSchema)
165
273
  },
166
274
  {
@@ -168,11 +276,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
168
276
  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, notify_user/await_reply won't work until the user pairs again with the pair tool.",
169
277
  inputSchema: json(z.object({}))
170
278
  },
279
+ {
280
+ name: "enable_tools",
281
+ description: "Allowlist Paigy's notify/await tools so they run WITHOUT an approval prompt each time \u2014 call this AFTER pairing, once the user has said yes to the `enable_prompt` (never without their consent). It writes the hosting tool's permission allowlist for you: `scope:'user'` (default) covers every project (~/.claude/settings.json), `scope:'project'` scopes it to this repo (.claude/settings.json). Merges into the existing file (never clobbers) and leaves pair/unpair OUT so they stay human-approved. Returns { ok, path, added, already } on success, or { ok:false, reason } when the host isn't Claude Code (add the ids to that tool's own allowlist by hand). Idempotent \u2014 re-running just reports everything already present.",
282
+ inputSchema: json(EnableToolsSchema)
283
+ },
171
284
  {
172
285
  name: "notify_user",
173
286
  description: "Notify the user via Paigy. Returns { notificationId, threadId } \u2014 pass notificationId to await_reply for the answer, threadId to notify_user to continue the conversation. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread (they leave the user's inbox; the response reports supersededCount) \u2014 right for updates to one ask, WRONG for a checklist: send parallel to-dos as separate un-threaded notifications. SIMPLEST FORM \u2014 just state what you need: { ask: \"I need to know whether to deploy the auth fix \u2014 tests are green, staging verified\", urgencyHint: 'now'|'soon'|'whenever', needs?: [\"deploy?\", \"keep the flag?\"] }. Paigy's broker derives the title, answer shape, options, and delivery channel for you \u2014 prefer this unless you specifically need to control the exact options/shape. The fully-shaped form below remains available and unchanged (the two are mutually exclusive: send `ask` OR `context`+`select`). FULLY-SHAPED FORM: provide context.title (a specific, non-empty one-line headline \u2014 this is what the user sees first, and what shows on the ring for a call) and context.description (an array of standalone, non-empty detail chunks the user can selectively ask you to expand). Set `urgency`: 'inbox' (default) drops it silently in their inbox; 'push' is a quiet passive notification (no sound); 'banner' sends a time-sensitive banner/lock-screen push (a 'paige') they tap to open \u2014 for when you need them soon-ish but not enough to ring them; 'call' rings their phone now as a voice call \u2014 only when you genuinely need them in the moment (blocked/waiting, time-sensitive). This is the premier use case for Paigy: getting UNBLOCKED so you can keep working, not just reporting that you're stuck. The user started a big task and went to do something else \u2014 the cardinal failure is going idle on one small decision and silently waiting to be checked on, so they come back to find you never actually progressed. Judge urgency by how much WORK IS BLOCKED behind this decision (a lot of dependent downstream work \u2192 call, even if it's a single question), not by how many things happen to be pending \u2014 one blocking decision outweighs five independent low-stakes ones sitting in the inbox. Set `blocking: true` whenever that's the case, independent of `urgency` \u2014 it's what lets Paigy escalate this to a real call later on its own if it goes unanswered, even if you sent it at a lower urgency. Don't set it for things you could work around, defer, or where other useful work exists meanwhile. ON A CALL, your title + description are READ ALOUD by a voice \u2014 write them to be HEARD, not read: keep it short and conversational, front-load the ask, and refer to things BY NAME, not by ID or code (say 'the pull request about the agents page', not 'PR #235'; 'the login-bug ticket', not 'ABC-1234'). Spell out only what's natural to say out loud. MATCH the answer shape to the question \u2014 `select` is required; pick the best tool for the job, not always yes/no. The user can ALWAYS add free text on top of any shape, so structuring loses nothing. Choose `select`: yes/no \u2192 select:'confirm' \u2192 {kind:'confirm', approved:boolean}. Approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve' \u2192 {kind:'confirm', approved:boolean}. Both are answerable right from the banner \u2014 no need to open the app. Pick one of several \u2192 options + select:'one' \u2192 {kind:'option', optionId}. Pick several / a subset \u2192 options + select:'many' \u2192 {kind:'multi', optionIds:[...]}. Rank or prioritize \u2192 options + select:'rank', user taps in preferred order \u2192 {kind:'ranked', optionIds:[...]}. One/many/rank/text all need the user to open the app to answer \u2014 only confirm is answerable straight from the banner. Options carry no ids \u2014 they're assigned by position ('1', '2', \u2026), and the answer's optionId(s) are those positions. For visual choices give each option a sandboxed `html` or an `image` preview (e.g. layout/UI alternatives); use `visuals` for images that set context for the whole question. select:'text' = free-form reply only (plain updates, or answers that genuinely can't be structured). If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail on those chunks \u2014 respond via notify_user with the SAME threadId and an expanded description. Pass `threadId` from a prior notify_user result or an await_reply reply to continue that conversation thread; omit it to start a new one. To follow up on a call (e.g. the user asked you to 'call me back when it's done'), reuse the threadId from that call's reply so it threads as the same conversation.",
174
287
  inputSchema: json(NotifyRequestSchema)
175
288
  },
289
+ {
290
+ // North-star Phase 1 (NORTH-STAR.md): `notify` is the general routing verb — attach
291
+ // attention to context and route it to a participant (MODEL.md §4). `notify_user` is
292
+ // kept as an alias (same input, same behavior) until every caller has moved; the
293
+ // `_user` is a naming holdover from when the only recipient was a human.
294
+ name: "notify",
295
+ description: "Route a notification via Paigy (the general form of notify_user \u2014 identical input and behavior). Prefer this name going forward. See notify_user for the full argument guide: the simple `ask` form, the fully-shaped context+select form, urgency levels, blocking, and threading.",
296
+ inputSchema: json(NotifyRequestSchema)
297
+ },
176
298
  {
177
299
  name: "await_reply",
178
300
  description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from notify_user). This is how you wait for your answer in-context. Polls ~5 min; returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (timed out this window, no answer yet). 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 notify_user 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 (notify_user with the reply's threadId) when the task is done or you hit a blocker \u2014 urgency:'call' for a blocker, 'banner'/'push'/'inbox' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). 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 (lower urgency). `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 (notify_user on the same threadId) or proceed knowingly partial; never treat a partial answer as complete.",
@@ -197,6 +319,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
197
319
  name: "schedule_callback",
198
320
  description: "Promise the user a follow-up you'll keep even if you go idle. Use it when they ask you to report back: trigger 'on_done' (when you finish \u2014 fires when you call set_task_state completed), 'on_blocked' (if you hit a blocker \u2014 fires on set_task_state needs_input), or 'scheduled' with dueInSeconds (e.g. 'remind me in 10 min'). Pass the threadId of the conversation and a short note. Fulfill it by calling notify_user on that threadId; check_replies re-lists due callbacks until you do.",
199
321
  inputSchema: json(ScheduleCallbackSchema)
322
+ },
323
+ {
324
+ name: "handoff",
325
+ 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 nickname, 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 `threadId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { threadId }.",
326
+ inputSchema: json(HandoffSchema)
200
327
  }
201
328
  ]
202
329
  }));
@@ -217,6 +344,7 @@ async function handleTool(request) {
217
344
  const code = await requestCode(void 0, offer);
218
345
  saveKeyFile({ ...keyFile, userCode: code.user_code });
219
346
  writeSurface("pairing code", code.user_code, code.expires_in);
347
+ startBackgroundPair(code.device_code, code.expires_in * 1e3);
220
348
  const qr = qrcode(0, "M");
221
349
  qr.addData(code.verification_uri_complete);
222
350
  qr.make();
@@ -236,63 +364,15 @@ Enter it in the Paigy app: Inbox \u2192 Add a new agent.` },
236
364
  user_message: `# ${code.user_code}
237
365
 
238
366
  Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
239
- message: `STOP HERE: end your turn now with \`user_message\` as your entire reply (the bare code, nothing else) \u2014 do NOT call pair again in this same turn, or the code text is dropped before the user sees it. Do NOT open a browser. Poll for approval by calling pair with this device_code on your NEXT turn, after you've shown the code. The user may prefer scanning \`qr\` (print it verbatim in a fenced code block on request).`
367
+ message: `SHOW the user \`user_message\` now (the bare code) so it isn't buried in prose, then call pair again with this device_code to finish \u2014 I'm ALREADY polling for approval in the background, so that call returns the moment they approve (it may still be pending, just call it again). You can do both in this same turn: emit the code text first, THEN call pair. Do NOT open a browser. The user may prefer scanning \`qr\` (print it verbatim in a fenced code block on request).`
240
368
  })
241
369
  }
242
370
  ]
243
371
  };
244
372
  }
245
- let kf = readKeyFile();
246
- const start = Date.now();
247
373
  const capMs = 9e4;
248
- while (Date.now() - start < capMs) {
249
- let step;
250
- try {
251
- step = await pairStep(device_code, kf);
252
- } catch (e) {
253
- clearSurface();
254
- throw e;
255
- }
256
- if (step.kind === "e2ee_aborted") {
257
- deleteKeyFile();
258
- kf = null;
259
- await sleep(2e3);
260
- continue;
261
- }
262
- if (step.kind === "awaiting_confirm") {
263
- return awaitingConfirmResult(step.sas, device_code);
264
- }
265
- if (step.kind === "paired") {
266
- saveToken(step.token);
267
- if (!step.sas || !kf) {
268
- deleteKeyFile();
269
- return pairedResult(step.token);
270
- }
271
- const finalDeadline = Math.min(Date.now() + 1e4, start + capMs);
272
- let e2ee = false;
273
- while (Date.now() < finalDeadline) {
274
- const cred = kf.userCode ? await fetchCredential(kf.userCode) : null;
275
- if (cred && step.uikPub) {
276
- e2ee = finalizeE2ee(kf, cred, step.uikPub);
277
- break;
278
- }
279
- await sleep(2e3);
280
- }
281
- if (!e2ee) deleteKeyFile();
282
- return pairedResult(step.token, step.sas, void 0, e2ee);
283
- }
284
- await sleep(2e3);
285
- }
286
- return {
287
- content: [{
288
- type: "text",
289
- text: JSON.stringify({
290
- status: "pending",
291
- device_code,
292
- message: "Still awaiting approval after ~90s. Call pair again with this device_code to keep waiting."
293
- })
294
- }]
295
- };
374
+ const outcome = await joinBackgroundPair(device_code, capMs) ?? await resolvePairing(device_code, capMs);
375
+ return renderPairOutcome(outcome, device_code);
296
376
  }
297
377
  case "unpair": {
298
378
  const token = readToken();
@@ -306,6 +386,7 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
306
386
  }
307
387
  const removed = deleteToken();
308
388
  deleteKeyFile();
389
+ cancelBackgroundPair();
309
390
  return {
310
391
  content: [{
311
392
  type: "text",
@@ -318,6 +399,14 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
318
399
  }]
319
400
  };
320
401
  }
402
+ case "enable_tools": {
403
+ const { scope } = EnableToolsSchema.parse(request.params.arguments ?? {});
404
+ const result = enablePaigyTools(scope ?? "user");
405
+ const message = result.ok ? `Allowlisted ${result.added.length} Paigy tool(s) in ${result.path}` + (result.already.length ? ` (${result.already.length} already present).` : ".") + " They now run without an approval prompt; restart/reload the tool if it caches settings. pair/unpair stay human-approved." : result.reason;
406
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, message }) }] };
407
+ }
408
+ // `notify` (the general routing verb) and `notify_user` (its alias) share one handler.
409
+ case "notify":
321
410
  case "notify_user": {
322
411
  const parsed = NotifyRequestSchema.parse(request.params.arguments);
323
412
  const problems = lintNotify(parsed);
@@ -358,6 +447,10 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
358
447
  const result = await scheduleCallback(ScheduleCallbackSchema.parse(request.params.arguments));
359
448
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
360
449
  }
450
+ case "handoff": {
451
+ const result = await handoff(HandoffSchema.parse(request.params.arguments));
452
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
453
+ }
361
454
  default:
362
455
  throw new Error(`Unknown tool: ${request.params.name}`);
363
456
  }