@paigy/mcp 0.16.0 → 0.17.1
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 +25 -1
- package/dist/{chunk-U3WODCOE.js → chunk-IMIXCPTN.js} +1 -1
- package/dist/chunk-MH6AHNUI.js +633 -0
- package/dist/{chunk-R3YGSGPX.js → chunk-RMTTO6BI.js} +108 -3
- package/dist/index.js +42 -4
- package/dist/listen.js +112 -544
- package/dist/onboard.js +2 -2
- package/dist/statusline.js +1 -1
- package/package.json +12 -11
|
@@ -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:
|
|
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
|
|
@@ -2504,7 +2555,15 @@ var PendingRepliesSchema = z.object({
|
|
|
2504
2555
|
* notify_user on the same threadId. Keeps reappearing until you call
|
|
2505
2556
|
* set_task_state on its notificationId. */
|
|
2506
2557
|
requests: z.array(
|
|
2507
|
-
z.object({
|
|
2558
|
+
z.object({
|
|
2559
|
+
threadId: z.string(),
|
|
2560
|
+
notificationId: z.string(),
|
|
2561
|
+
text: z.string(),
|
|
2562
|
+
createdAt: z.string(),
|
|
2563
|
+
/** The user seeded this request with a past conversation — call get_thread on it
|
|
2564
|
+
* FIRST and treat the transcript as prior context (#57/#251). */
|
|
2565
|
+
contextThreadId: z.string().optional()
|
|
2566
|
+
})
|
|
2508
2567
|
),
|
|
2509
2568
|
/** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
|
|
2510
2569
|
* if blocked, or at a time that has passed). Re-surfaced every sweep until you
|
|
@@ -2540,8 +2599,13 @@ var UserResponseSchema = z.object({
|
|
|
2540
2599
|
/** Coverage report (#396): which of the ask's declared `points` were addressed. */
|
|
2541
2600
|
covered: z.array(z.string()).optional()
|
|
2542
2601
|
});
|
|
2602
|
+
var VoiceKeySchema = z.enum(["rachel", "george", "jessica", "brian", "lily"]);
|
|
2543
2603
|
var InboxItemSchema = z.object({
|
|
2544
2604
|
id: z.string(),
|
|
2605
|
+
/** The conversation thread + connection this item lives on. Present on the replied
|
|
2606
|
+
* detail — they power History's "Continue" / "New session from this" (#57/#251). */
|
|
2607
|
+
threadId: z.string().optional(),
|
|
2608
|
+
tokenId: z.string().optional(),
|
|
2545
2609
|
status: NotifyStatusSchema,
|
|
2546
2610
|
context: ContextSchema,
|
|
2547
2611
|
options: z.array(OptionSchema).optional(),
|
|
@@ -2556,6 +2620,8 @@ var InboxItemSchema = z.object({
|
|
|
2556
2620
|
visuals: z.array(VisualSchema).optional(),
|
|
2557
2621
|
agent: z.string(),
|
|
2558
2622
|
nickname: z.string(),
|
|
2623
|
+
/** The pairing's assigned voice (#462); absent = the default voice. */
|
|
2624
|
+
voice: VoiceKeySchema.optional(),
|
|
2559
2625
|
repo: z.string().optional(),
|
|
2560
2626
|
branch: z.string().optional(),
|
|
2561
2627
|
createdAt: z.string().datetime(),
|
|
@@ -2675,6 +2741,8 @@ var ConnectionSummarySchema = z.object({
|
|
|
2675
2741
|
agent: z.string(),
|
|
2676
2742
|
device: z.string().nullable(),
|
|
2677
2743
|
nickname: z.string(),
|
|
2744
|
+
/** The pairing's assigned voice (#462); null = the default voice. */
|
|
2745
|
+
voice: VoiceKeySchema.nullable(),
|
|
2678
2746
|
createdAt: z.string().datetime(),
|
|
2679
2747
|
/** Most recent notification on this connection, either direction. Null = no contact yet.
|
|
2680
2748
|
* Drives the agents-page recency grouping (Today / This week / …). */
|
|
@@ -2687,7 +2755,25 @@ var CreateRequestSchema = z.object({
|
|
|
2687
2755
|
/** The connection (token id) to send to, from GET /api/tokens. */
|
|
2688
2756
|
tokenId: z.string(),
|
|
2689
2757
|
/** The user's message to the agent. */
|
|
2690
|
-
text: z.string().min(1)
|
|
2758
|
+
text: z.string().min(1),
|
|
2759
|
+
/** Land the request on an existing conversation thread (History → "Continue")
|
|
2760
|
+
* instead of minting a fresh one. Must belong to the requesting user. */
|
|
2761
|
+
threadId: z.string().optional(),
|
|
2762
|
+
/** Point the agent at a past conversation (possibly with a different agent) as
|
|
2763
|
+
* starting context (History → "New session from this"). A reference, not a copy —
|
|
2764
|
+
* the agent reads it via get_thread. Must belong to the requesting user. */
|
|
2765
|
+
contextThreadId: z.string().optional()
|
|
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()
|
|
2691
2777
|
});
|
|
2692
2778
|
var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
|
|
2693
2779
|
var RegisterDeliverySchema = z.object({ mode: DeliveryModeSchema });
|
|
@@ -3426,6 +3512,13 @@ function landIntents(intents) {
|
|
|
3426
3512
|
return due !== null ? { ...i, dueInSeconds: due } : i;
|
|
3427
3513
|
});
|
|
3428
3514
|
}
|
|
3515
|
+
async function getThread(threadId) {
|
|
3516
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/thread/${encodeURIComponent(threadId)}`, {
|
|
3517
|
+
headers: { authorization: `Bearer ${readToken()}` }
|
|
3518
|
+
}));
|
|
3519
|
+
if (!res.ok) throw new Error(`get_thread failed: ${res.status} ${await res.text()}`);
|
|
3520
|
+
return await res.json();
|
|
3521
|
+
}
|
|
3429
3522
|
async function checkReplies() {
|
|
3430
3523
|
const token = readToken();
|
|
3431
3524
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/pending`, {
|
|
@@ -3471,6 +3564,16 @@ async function scheduleCallback(req) {
|
|
|
3471
3564
|
if (!res.ok) throw new Error(`schedule_callback failed: ${res.status} ${await res.text()}`);
|
|
3472
3565
|
return await res.json();
|
|
3473
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
|
+
}
|
|
3474
3577
|
var TITLE_MAX = 90;
|
|
3475
3578
|
var CHUNKS_MAX = 8;
|
|
3476
3579
|
var CHUNK_MAX = 300;
|
|
@@ -3539,9 +3642,11 @@ export {
|
|
|
3539
3642
|
UnpairedError,
|
|
3540
3643
|
submitNotification,
|
|
3541
3644
|
awaitReply,
|
|
3645
|
+
getThread,
|
|
3542
3646
|
checkReplies,
|
|
3543
3647
|
setTaskState,
|
|
3544
3648
|
registerDelivery,
|
|
3545
3649
|
scheduleCallback,
|
|
3650
|
+
handoff,
|
|
3546
3651
|
lintNotify
|
|
3547
3652
|
};
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
HandoffSchema
|
|
4
|
+
} from "./chunk-MH6AHNUI.js";
|
|
2
5
|
import {
|
|
3
6
|
autoConfigureClients,
|
|
4
7
|
claudeInstallHint
|
|
5
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-IMIXCPTN.js";
|
|
6
9
|
import {
|
|
7
10
|
clearSurface,
|
|
8
11
|
writeSurface
|
|
@@ -18,6 +21,8 @@ import {
|
|
|
18
21
|
deleteToken,
|
|
19
22
|
fetchCredential,
|
|
20
23
|
finalizeE2ee,
|
|
24
|
+
getThread,
|
|
25
|
+
handoff,
|
|
21
26
|
lintNotify,
|
|
22
27
|
pairStep,
|
|
23
28
|
readKeyFile,
|
|
@@ -31,7 +36,7 @@ import {
|
|
|
31
36
|
sleep,
|
|
32
37
|
startE2ee,
|
|
33
38
|
submitNotification
|
|
34
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-RMTTO6BI.js";
|
|
35
40
|
|
|
36
41
|
// src/index.ts
|
|
37
42
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -70,13 +75,16 @@ function json(s) {
|
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
// src/index.ts
|
|
73
|
-
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 @paigy/mcp paigy-mcp-onboard`.";
|
|
78
|
+
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`.";
|
|
74
79
|
var AwaitReplySchema = z.object({
|
|
75
80
|
notificationId: z.string().describe("The notificationId returned by notify_user \u2014 waits for the user's reply to THIS notification only.")
|
|
76
81
|
});
|
|
77
82
|
var PairSchema = z.object({
|
|
78
83
|
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.")
|
|
79
84
|
});
|
|
85
|
+
var GetThreadSchema = z.object({
|
|
86
|
+
threadId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
|
|
87
|
+
});
|
|
80
88
|
var SetTaskStateToolSchema = z.object({
|
|
81
89
|
notificationId: z.string(),
|
|
82
90
|
state: SetTaskStateSchema.shape.state
|
|
@@ -121,6 +129,7 @@ function pairedResult(token, sas, note, e2ee) {
|
|
|
121
129
|
if (hint) base.claude_hint = hint;
|
|
122
130
|
} catch {
|
|
123
131
|
}
|
|
132
|
+
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.";
|
|
124
133
|
return { content: [{ type: "text", text: JSON.stringify(base) }] };
|
|
125
134
|
}
|
|
126
135
|
function awaitingConfirmResult(sas, device_code) {
|
|
@@ -148,7 +157,7 @@ var server = new Server(
|
|
|
148
157
|
{ name: "paigy", version: "0.0.0" },
|
|
149
158
|
{
|
|
150
159
|
capabilities: { tools: {} },
|
|
151
|
-
instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one 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 notify_user + await_reply. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see notify_user's own description for how to respond. When urgency is 'call', remember the title + description are spoken aloud \u2014 write them short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can 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. Escalate silence, don't just wait on it: if you notified at a lower urgency (inbox/push/banner) for something that's genuinely blocking real progress, call await_reply up to twice (~5 min each, ~10 min total) \u2014 if it's still idle after that AND the item is genuinely blocking, send a fresh notify_user on the SAME threadId at urgency:'call'. Skip this for anything that isn't truly blocking; a normal question can just sit in the inbox. 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."
|
|
160
|
+
instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose threadId you don't recognize, or one carrying a contextThreadId, 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 notify_user + await_reply. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see notify_user's own description for how to respond. When urgency is 'call', remember the title + description are spoken aloud \u2014 write them short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can 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. Escalate silence, don't just wait on it: if you notified at a lower urgency (inbox/push/banner) for something that's genuinely blocking real progress, call await_reply up to twice (~5 min each, ~10 min total) \u2014 if it's still idle after that AND the item is genuinely blocking, send a fresh notify_user on the SAME threadId at urgency:'call'. Skip this for anything that isn't truly blocking; a normal question can just sit in the inbox. 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."
|
|
152
161
|
}
|
|
153
162
|
);
|
|
154
163
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -168,6 +177,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
168
177
|
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.",
|
|
169
178
|
inputSchema: json(NotifyRequestSchema)
|
|
170
179
|
},
|
|
180
|
+
{
|
|
181
|
+
// North-star Phase 1 (NORTH-STAR.md): `notify` is the general routing verb — attach
|
|
182
|
+
// attention to context and route it to a participant (MODEL.md §4). `notify_user` is
|
|
183
|
+
// kept as an alias (same input, same behavior) until every caller has moved; the
|
|
184
|
+
// `_user` is a naming holdover from when the only recipient was a human.
|
|
185
|
+
name: "notify",
|
|
186
|
+
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.",
|
|
187
|
+
inputSchema: json(NotifyRequestSchema)
|
|
188
|
+
},
|
|
171
189
|
{
|
|
172
190
|
name: "await_reply",
|
|
173
191
|
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.",
|
|
@@ -178,6 +196,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
178
196
|
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, threadId, 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. 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 notify_user call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with notify_user on its threadId. 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. 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.",
|
|
179
197
|
inputSchema: json(z.object({}))
|
|
180
198
|
},
|
|
199
|
+
{
|
|
200
|
+
name: "get_thread",
|
|
201
|
+
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 threadId you don't recognize means the user is continuing an old conversation with you, and one carrying a contextThreadId 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 } and { role:'user', text }, oldest first, capped at the most recent 30.",
|
|
202
|
+
inputSchema: json(GetThreadSchema)
|
|
203
|
+
},
|
|
181
204
|
{
|
|
182
205
|
name: "set_task_state",
|
|
183
206
|
description: "Report progress on the follow-up work behind ANY notification you own \u2014 a user-initiated request (from check_replies), or your OWN notify_user 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 notify_user carrying parentId = the same notificationId you're reporting on). Calling this reliably is also what lets a future session's check_replies surface `stalled` work you (or a crashed/idle prior session) left at in_progress without ever reporting completed.",
|
|
@@ -187,6 +210,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
187
210
|
name: "schedule_callback",
|
|
188
211
|
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.",
|
|
189
212
|
inputSchema: json(ScheduleCallbackSchema)
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "handoff",
|
|
216
|
+
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 }.",
|
|
217
|
+
inputSchema: json(HandoffSchema)
|
|
190
218
|
}
|
|
191
219
|
]
|
|
192
220
|
}));
|
|
@@ -308,6 +336,8 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
|
308
336
|
}]
|
|
309
337
|
};
|
|
310
338
|
}
|
|
339
|
+
// `notify` (the general routing verb) and `notify_user` (its alias) share one handler.
|
|
340
|
+
case "notify":
|
|
311
341
|
case "notify_user": {
|
|
312
342
|
const parsed = NotifyRequestSchema.parse(request.params.arguments);
|
|
313
343
|
const problems = lintNotify(parsed);
|
|
@@ -335,6 +365,10 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
|
335
365
|
const result = await checkReplies();
|
|
336
366
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
337
367
|
}
|
|
368
|
+
case "get_thread": {
|
|
369
|
+
const { threadId } = GetThreadSchema.parse(request.params.arguments);
|
|
370
|
+
return { content: [{ type: "text", text: JSON.stringify(await getThread(threadId)) }] };
|
|
371
|
+
}
|
|
338
372
|
case "set_task_state": {
|
|
339
373
|
const { notificationId, state } = SetTaskStateToolSchema.parse(request.params.arguments);
|
|
340
374
|
const result = await setTaskState(notificationId, state);
|
|
@@ -344,6 +378,10 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
|
344
378
|
const result = await scheduleCallback(ScheduleCallbackSchema.parse(request.params.arguments));
|
|
345
379
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
346
380
|
}
|
|
381
|
+
case "handoff": {
|
|
382
|
+
const result = await handoff(HandoffSchema.parse(request.params.arguments));
|
|
383
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
384
|
+
}
|
|
347
385
|
default:
|
|
348
386
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
349
387
|
}
|