@paigy/mcp 0.24.0 → 0.25.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 +12 -8
- package/dist/{chunk-JIQKMDUU.js → chunk-63VXNOEC.js} +3 -2
- package/dist/{chunk-F4SA2TWM.js → chunk-GWYHSAU5.js} +36 -4
- package/dist/{chunk-ZLN2C6IY.js → chunk-RXOUZ7WR.js} +20 -1
- package/dist/index.js +71 -30
- package/dist/listen.js +2 -2
- package/dist/onboard.js +2 -2
- package/dist/statusline.js +1 -1
- package/package.json +14 -13
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A voice inbox for your AI agents. This MCP server lets an agent **notify a user*
|
|
|
7
7
|
### As a Claude Code plugin (one step)
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
/plugin marketplace add paigy-ai/
|
|
10
|
+
/plugin marketplace add paigy-ai/mcp
|
|
11
11
|
/plugin install paigy
|
|
12
12
|
```
|
|
13
13
|
|
|
@@ -57,10 +57,12 @@ codex plugin marketplace add paigy-ai/mcp --ref main
|
|
|
57
57
|
codex plugin add paigy@paigy-ai
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
Then pair your phone
|
|
60
|
+
Then pair your phone — under the same agent name the plugin's MCP runs as
|
|
61
|
+
(the token is saved per agent; a mismatched name leaves Codex "not paired"
|
|
62
|
+
against an approved pairing):
|
|
61
63
|
|
|
62
64
|
```bash
|
|
63
|
-
npx -y -p @paigy/mcp@latest paigy-mcp-onboard
|
|
65
|
+
PAIGY_AGENT=codex npx -y -p @paigy/mcp@latest paigy-mcp-onboard
|
|
64
66
|
```
|
|
65
67
|
|
|
66
68
|
Or add the MCP server directly (writes `~/.codex/config.toml`):
|
|
@@ -78,7 +80,7 @@ args = ["-y", "@paigy/mcp@latest"]
|
|
|
78
80
|
env = { PAIGY_AGENT = "codex" }
|
|
79
81
|
```
|
|
80
82
|
|
|
81
|
-
Then pair: `npx -p @paigy/mcp@latest paigy-mcp-onboard` (or have the agent call the `pair` tool).
|
|
83
|
+
Then pair: `PAIGY_AGENT=codex npx -p @paigy/mcp@latest paigy-mcp-onboard` (or have the agent call the `pair` tool — it pairs under its own name automatically).
|
|
82
84
|
|
|
83
85
|
### Gemini CLI
|
|
84
86
|
|
|
@@ -101,17 +103,19 @@ Or add the entry to `~/.gemini/settings.json`:
|
|
|
101
103
|
}
|
|
102
104
|
```
|
|
103
105
|
|
|
104
|
-
Then pair: `npx -p @paigy/mcp@latest paigy-mcp-onboard`.
|
|
106
|
+
Then pair: `PAIGY_AGENT=gemini npx -p @paigy/mcp@latest paigy-mcp-onboard`.
|
|
105
107
|
|
|
106
|
-
> `PAIGY_AGENT`
|
|
107
|
-
> `mcp-agent`)
|
|
108
|
+
> `PAIGY_AGENT` names the agent AND keys its token slot (defaults to
|
|
109
|
+
> `mcp-agent`). Each client reads only its own slot, so pair with the same
|
|
110
|
+
> `PAIGY_AGENT` the client's config uses — and set it per client so you can
|
|
111
|
+
> tell your connected agents apart.
|
|
108
112
|
|
|
109
113
|
## Tools
|
|
110
114
|
|
|
111
115
|
- **`pair`** — pair this agent with the user's Paigy account (one-time). No args to start: returns the code to show the user **and begins polling for approval in the background**; pass the returned `device_code` to collect the result (it returns the moment the user approves). On success it prompts you to allowlist Paigy's notify/await tools so they run without an approval prompt each time.
|
|
112
116
|
- **`unpair`** — log this agent out of the user's Paigy account; revokes the token server-side and deletes the local one.
|
|
113
117
|
- **`enable_tools`** — after pairing (and only with the user's consent), allowlist Paigy's notify/await tools so they run without an approval prompt each time. Writes Claude Code's `permissions.allow` — `scope:'user'` (default, every project) or `scope:'project'` (this repo). Merges, never clobbers; leaves `pair`/`unpair` human-approved.
|
|
114
|
-
- **`
|
|
118
|
+
- **`contact`** — THE way to reach the user: tell them something, or ask and get their answer. Core form is two fields — `ask` (plain prose: what you need to tell them or find out) and `waiting` (what happens to your work meanwhile: `none` = just informing, `soft` = want an answer but can keep working, `hard` = stopped until answered — reaches them urgently and escalates to a real phone call). Paigy's broker picks the channel, phrasing, and answer format. When the choices themselves must be *seen*, attach `options` (each may carry a sandboxed `html` or `image` preview); attach `visuals` for context screenshots. Returns `{ notificationId, threadId }`; a threaded follow-up supersedes that thread's pending items, and an identical threaded re-send escalates in place. If a reply comes back as `{kind:'clarify', chunks:[...]}`, contact again on the SAME threadId with an expanded ask. (The former `notify_user`/`notify` names remain accepted as hidden aliases for older setups, and the fully-shaped wire form — context/select/urgency — is still accepted from code; neither is part of the model surface anymore.)
|
|
115
119
|
- **`await_reply`** — wait for the user's reply to a specific notification (pass its notificationId). Scoped: will not return replies meant for other notifications. Returns `reply` / `remind` / `idle`.
|
|
116
120
|
- **`check_replies`** — catch-up sweep: returns replies you haven't consumed yet (now marked seen) plus still-pending notifications, new user-initiated requests, and owed callbacks.
|
|
117
121
|
- **`set_task_state`** — report progress on a request: `in_progress` / `completed` / `needs_input`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AGENT_NAME
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-GWYHSAU5.js";
|
|
4
4
|
|
|
5
5
|
// src/clients.ts
|
|
6
6
|
import { execFile } from "child_process";
|
|
@@ -85,9 +85,10 @@ function claudeInstallHint(skip = AGENT_NAME) {
|
|
|
85
85
|
if (existsSync(registry) && readFileSync(registry, "utf8").includes('"paigy@')) return null;
|
|
86
86
|
} catch {
|
|
87
87
|
}
|
|
88
|
-
return "Claude Code detected \u2014 to add Paigy there, run /plugin marketplace add paigy-ai/
|
|
88
|
+
return "Claude Code detected \u2014 to add Paigy there, run /plugin marketplace add paigy-ai/mcp then /plugin install paigy (pairing carries over; no need to pair again). Note: If you run this inside a live session, type /reload-plugins afterward so the agent connects to the new tools.";
|
|
89
89
|
}
|
|
90
90
|
var PAIGY_TOOL_IDS = [
|
|
91
|
+
"mcp__paigy__contact",
|
|
91
92
|
"mcp__paigy__notify_user",
|
|
92
93
|
"mcp__paigy__notify",
|
|
93
94
|
"mcp__paigy__await_reply",
|
|
@@ -2405,6 +2405,20 @@ var NotifyRequestSchema = z.object({
|
|
|
2405
2405
|
urgencyHint: z.enum(["whenever", "soon", "now"]).optional().describe(
|
|
2406
2406
|
"With `ask` only: how urgently you need the answer \u2014 'whenever' (inbox), 'soon' (worth a heads-up), 'now' (you're blocked this minute). A hint, not a command: the user's settings still have the final word."
|
|
2407
2407
|
),
|
|
2408
|
+
/** #575: the ONE self-report that replaces urgencyHint + blocking — what happens
|
|
2409
|
+
* to the agent's work while it waits. Normalized server-side into those two
|
|
2410
|
+
* fields (normalizeWaiting) so everything downstream is untouched; explicit
|
|
2411
|
+
* urgencyHint/blocking win when both are sent. */
|
|
2412
|
+
waiting: z.enum(["none", "soft", "hard"]).optional().describe(
|
|
2413
|
+
"With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
|
|
2414
|
+
),
|
|
2415
|
+
/** #575: a RELAY of the user's explicitly stated preference, never the agent's
|
|
2416
|
+
* choice. Outranks waiting in both directions: 'call' rings even for a
|
|
2417
|
+
* waiting:'none' "call me when it's done"; 'message' never rings even for
|
|
2418
|
+
* waiting:'hard'. */
|
|
2419
|
+
channel: z.enum(["call", "message"]).optional().describe(
|
|
2420
|
+
"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."
|
|
2421
|
+
),
|
|
2408
2422
|
confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
|
|
2409
2423
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
2410
2424
|
),
|
|
@@ -2429,7 +2443,7 @@ var NotifyRequestSchema = z.object({
|
|
|
2429
2443
|
return;
|
|
2430
2444
|
}
|
|
2431
2445
|
if (r.ask !== void 0) {
|
|
2432
|
-
for (const f of ["context", "
|
|
2446
|
+
for (const f of ["context", "select", "points"]) {
|
|
2433
2447
|
if (r[f] !== void 0)
|
|
2434
2448
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it (use \`needs\` for multi-part asks)` });
|
|
2435
2449
|
}
|
|
@@ -2447,17 +2461,30 @@ var NotifyRequestSchema = z.object({
|
|
|
2447
2461
|
if (!needsOptions && r.options?.length)
|
|
2448
2462
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
|
|
2449
2463
|
});
|
|
2464
|
+
function normalizeWaiting(req) {
|
|
2465
|
+
if (!req.waiting) return req;
|
|
2466
|
+
const { waiting, ...rest } = req;
|
|
2467
|
+
return {
|
|
2468
|
+
...rest,
|
|
2469
|
+
urgencyHint: req.urgencyHint ?? { none: "whenever", soft: "soon", hard: "now" }[waiting],
|
|
2470
|
+
blocking: req.blocking || waiting === "hard"
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2450
2473
|
function deriveAsk(req) {
|
|
2474
|
+
req = normalizeWaiting(req);
|
|
2451
2475
|
if (!req.ask) return req;
|
|
2452
2476
|
const text = req.ask.trim();
|
|
2453
2477
|
const firstSentence = (/^[^.!?\n]+[.!?]?/.exec(text)?.[0] ?? text).trim();
|
|
2454
2478
|
const title = firstSentence.length > 90 ? `${firstSentence.slice(0, 87).trimEnd()}\u2026` : firstSentence;
|
|
2455
|
-
const
|
|
2456
|
-
const
|
|
2479
|
+
const hinted = req.urgencyHint === "now" ? "call" : req.urgencyHint === "soon" ? "banner" : req.urgencyHint === "whenever" ? "inbox" : req.urgency;
|
|
2480
|
+
const urgency = req.channel === "call" ? "call" : req.channel === "message" && hinted === "call" ? "banner" : hinted;
|
|
2481
|
+
const { ask: _ask, needs, urgencyHint: _hint, channel: _channel, ...rest } = req;
|
|
2457
2482
|
return {
|
|
2458
2483
|
...rest,
|
|
2459
2484
|
context: { title, description: [text] },
|
|
2460
|
-
|
|
2485
|
+
// Options riding alongside the ask (#575: pixels can't be prose) floor to a
|
|
2486
|
+
// single pick — the model broker may upgrade to many/rank from the wording.
|
|
2487
|
+
select: req.options?.length ? "one" : "text",
|
|
2461
2488
|
urgency,
|
|
2462
2489
|
...needs?.length ? { points: needs } : {}
|
|
2463
2490
|
};
|
|
@@ -2876,6 +2903,11 @@ var DeviceInfoSchema = z.object({
|
|
|
2876
2903
|
/** The agent's suggested name (from /device/code) — shown on the approval screen,
|
|
2877
2904
|
* pre-filling the name field the human can edit. */
|
|
2878
2905
|
name: z.string(),
|
|
2906
|
+
/** @deprecated Legacy alias of `name` for the pre-#531 embedded bundle in App Store
|
|
2907
|
+
* build 35, whose DeviceFlow renders `info.agent.slice(0, 2)` — without this a FRESH
|
|
2908
|
+
* install crashes on the pairing screen on first launch, before the OTA lands
|
|
2909
|
+
* (seen live: PAIGY-5T, 2026-07-21). Remove once a newer binary is the floor. */
|
|
2910
|
+
agent: z.string().optional(),
|
|
2879
2911
|
device: z.string().nullable(),
|
|
2880
2912
|
status: PairingStatusSchema,
|
|
2881
2913
|
proto: z.string().nullable().optional(),
|
|
@@ -131,6 +131,20 @@ var NotifyRequestSchema = z.object({
|
|
|
131
131
|
urgencyHint: z.enum(["whenever", "soon", "now"]).optional().describe(
|
|
132
132
|
"With `ask` only: how urgently you need the answer \u2014 'whenever' (inbox), 'soon' (worth a heads-up), 'now' (you're blocked this minute). A hint, not a command: the user's settings still have the final word."
|
|
133
133
|
),
|
|
134
|
+
/** #575: the ONE self-report that replaces urgencyHint + blocking — what happens
|
|
135
|
+
* to the agent's work while it waits. Normalized server-side into those two
|
|
136
|
+
* fields (normalizeWaiting) so everything downstream is untouched; explicit
|
|
137
|
+
* urgencyHint/blocking win when both are sent. */
|
|
138
|
+
waiting: z.enum(["none", "soft", "hard"]).optional().describe(
|
|
139
|
+
"With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
|
|
140
|
+
),
|
|
141
|
+
/** #575: a RELAY of the user's explicitly stated preference, never the agent's
|
|
142
|
+
* choice. Outranks waiting in both directions: 'call' rings even for a
|
|
143
|
+
* waiting:'none' "call me when it's done"; 'message' never rings even for
|
|
144
|
+
* waiting:'hard'. */
|
|
145
|
+
channel: z.enum(["call", "message"]).optional().describe(
|
|
146
|
+
"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."
|
|
147
|
+
),
|
|
134
148
|
confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
|
|
135
149
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
136
150
|
),
|
|
@@ -155,7 +169,7 @@ var NotifyRequestSchema = z.object({
|
|
|
155
169
|
return;
|
|
156
170
|
}
|
|
157
171
|
if (r.ask !== void 0) {
|
|
158
|
-
for (const f of ["context", "
|
|
172
|
+
for (const f of ["context", "select", "points"]) {
|
|
159
173
|
if (r[f] !== void 0)
|
|
160
174
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it (use \`needs\` for multi-part asks)` });
|
|
161
175
|
}
|
|
@@ -589,6 +603,11 @@ var DeviceInfoSchema = z.object({
|
|
|
589
603
|
/** The agent's suggested name (from /device/code) — shown on the approval screen,
|
|
590
604
|
* pre-filling the name field the human can edit. */
|
|
591
605
|
name: z.string(),
|
|
606
|
+
/** @deprecated Legacy alias of `name` for the pre-#531 embedded bundle in App Store
|
|
607
|
+
* build 35, whose DeviceFlow renders `info.agent.slice(0, 2)` — without this a FRESH
|
|
608
|
+
* install crashes on the pairing screen on first launch, before the OTA lands
|
|
609
|
+
* (seen live: PAIGY-5T, 2026-07-21). Remove once a newer binary is the floor. */
|
|
610
|
+
agent: z.string().optional(),
|
|
592
611
|
device: z.string().nullable(),
|
|
593
612
|
status: PairingStatusSchema,
|
|
594
613
|
proto: z.string().nullable().optional(),
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
HandoffSchema
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-RXOUZ7WR.js";
|
|
5
5
|
import {
|
|
6
6
|
PAIGY_TOOL_IDS,
|
|
7
7
|
autoConfigureClients,
|
|
8
8
|
claudeInstallHint,
|
|
9
9
|
enablePaigyTools
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-63VXNOEC.js";
|
|
11
11
|
import {
|
|
12
12
|
clearSurface,
|
|
13
13
|
writeSurface
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
sleep,
|
|
39
39
|
startE2ee,
|
|
40
40
|
submitNotification
|
|
41
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-GWYHSAU5.js";
|
|
42
42
|
|
|
43
43
|
// src/index.ts
|
|
44
44
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -76,6 +76,49 @@ function json(s) {
|
|
|
76
76
|
return draft2020(schema);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// src/toolset.ts
|
|
80
|
+
var CONTACT_SCHEMA = {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
ask: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "What to tell the user, or what you need to find out from them. Plain prose, one or two sentences; may be spoken aloud on a call, so write natural speech and name things (not IDs)."
|
|
86
|
+
},
|
|
87
|
+
waiting: {
|
|
88
|
+
type: "string",
|
|
89
|
+
enum: ["none", "soft", "hard"],
|
|
90
|
+
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."
|
|
91
|
+
},
|
|
92
|
+
options: {
|
|
93
|
+
type: "array",
|
|
94
|
+
items: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
label: { type: "string" },
|
|
98
|
+
html: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description: "Optional sandboxed HTML/CSS preview (no JS, no network, inline CSS + data: URIs, \u226416KB)."
|
|
101
|
+
},
|
|
102
|
+
image: { type: "string", description: "Optional hosted image URL shown as this option's preview." }
|
|
103
|
+
},
|
|
104
|
+
required: ["label"]
|
|
105
|
+
},
|
|
106
|
+
description: "The choices the user picks from, when you have them."
|
|
107
|
+
},
|
|
108
|
+
channel: {
|
|
109
|
+
type: "string",
|
|
110
|
+
enum: ["call", "message"],
|
|
111
|
+
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."
|
|
112
|
+
},
|
|
113
|
+
threadId: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description: "To continue an earlier conversation, pass the threadId a previous contact or reply returned. Omit to start a new one."
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
required: ["ask"]
|
|
119
|
+
};
|
|
120
|
+
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, threadId } \u2014 pass notificationId to await_reply for the answer, threadId to a later contact to continue the conversation. 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 threadId with an expanded ask.";
|
|
121
|
+
|
|
79
122
|
// src/pairing.ts
|
|
80
123
|
async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
|
|
81
124
|
let kf = readKeyFile();
|
|
@@ -154,7 +197,7 @@ function cancelBackgroundPair() {
|
|
|
154
197
|
// src/index.ts
|
|
155
198
|
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`.";
|
|
156
199
|
var AwaitReplySchema = z.object({
|
|
157
|
-
notificationId: z.string().describe("The notificationId returned by
|
|
200
|
+
notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only.")
|
|
158
201
|
});
|
|
159
202
|
var PairSchema = z.object({
|
|
160
203
|
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.")
|
|
@@ -260,19 +303,27 @@ var server = new Server(
|
|
|
260
303
|
{ name: "paigy", version: "0.0.0" },
|
|
261
304
|
{
|
|
262
305
|
capabilities: { tools: {} },
|
|
263
|
-
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
|
|
306
|
+
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 contact + 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 contact'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, re-send contact on the SAME threadId at urgency:'call' with the SAME content \u2014 identical content on an existing thread escalates the notification already sitting there instead of creating a second one, so don't reword the title/description just to escalate. Reword only when you genuinely have new information, which correctly becomes a new notification on the thread. 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."
|
|
264
307
|
}
|
|
265
308
|
);
|
|
266
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
267
|
-
tools
|
|
309
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
310
|
+
const tools = [
|
|
311
|
+
{
|
|
312
|
+
// #575: THE attention verb — the one model-facing surface, every agent.
|
|
313
|
+
// The retired names (notify/notify_user) stay callable as hidden aliases
|
|
314
|
+
// for stale prompts and cached servers; see toolset.ts for the design.
|
|
315
|
+
name: "contact",
|
|
316
|
+
description: CONTACT_DESCRIPTION,
|
|
317
|
+
inputSchema: CONTACT_SCHEMA
|
|
318
|
+
},
|
|
268
319
|
{
|
|
269
320
|
name: "pair",
|
|
270
|
-
description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before
|
|
321
|
+
description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before contact/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. 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 ask the user to allowlist Paigy's tools so notify/await don't prompt each time.",
|
|
271
322
|
inputSchema: json(PairSchema)
|
|
272
323
|
},
|
|
273
324
|
{
|
|
274
325
|
name: "unpair",
|
|
275
|
-
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,
|
|
326
|
+
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.",
|
|
276
327
|
inputSchema: json(z.object({}))
|
|
277
328
|
},
|
|
278
329
|
{
|
|
@@ -280,28 +331,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
280
331
|
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.",
|
|
281
332
|
inputSchema: json(EnableToolsSchema)
|
|
282
333
|
},
|
|
283
|
-
{
|
|
284
|
-
name: "notify_user",
|
|
285
|
-
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. A threaded re-send with IDENTICAL content instead escalates the pending ask in place (the response reports escalated: true and returns the SAME notificationId, so any await_reply you already have on it keeps working). 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.",
|
|
286
|
-
inputSchema: json(NotifyRequestSchema)
|
|
287
|
-
},
|
|
288
|
-
{
|
|
289
|
-
// North-star Phase 1 (NORTH-STAR.md): `notify` is the general routing verb — attach
|
|
290
|
-
// attention to context and route it to a participant (MODEL.md §4). `notify_user` is
|
|
291
|
-
// kept as an alias (same input, same behavior) until every caller has moved; the
|
|
292
|
-
// `_user` is a naming holdover from when the only recipient was a human.
|
|
293
|
-
name: "notify",
|
|
294
|
-
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.",
|
|
295
|
-
inputSchema: json(NotifyRequestSchema)
|
|
296
|
-
},
|
|
297
334
|
{
|
|
298
335
|
name: "await_reply",
|
|
299
|
-
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from
|
|
336
|
+
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from contact). This is how you wait for your answer in-context. Polls ~5 min; returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (timed out this window, no answer yet). On idle, if this is genuinely still blocking you and you have nothing else useful to do meanwhile, just call await_reply again immediately \u2014 keep looping. This is how you actually deliver on the point of calling: the user steps away for a while and comes back to find you'd already continued the moment they answered, not idle waiting to be checked on. Don't give up after one window. Only stop looping to do other work (and check back later), or after an unreasonably long stretch (tens of minutes to hours) worth telling the user about instead. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications, so concurrent contact calls don't cross. A CALL answer can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a blocking question'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's threadId) when the task is done or you hit a blocker \u2014 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 (contact on the same threadId) or proceed knowingly partial; never treat a partial answer as complete.",
|
|
300
337
|
inputSchema: json(AwaitReplySchema)
|
|
301
338
|
},
|
|
302
339
|
{
|
|
303
340
|
name: "check_replies",
|
|
304
|
-
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
|
|
341
|
+
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 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 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.",
|
|
305
342
|
inputSchema: json(z.object({}))
|
|
306
343
|
},
|
|
307
344
|
{
|
|
@@ -311,12 +348,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
311
348
|
},
|
|
312
349
|
{
|
|
313
350
|
name: "set_task_state",
|
|
314
|
-
description: "Report progress on the follow-up work behind ANY notification you own \u2014 a user-initiated request (from check_replies), or your OWN
|
|
351
|
+
description: "Report progress on the follow-up work behind ANY notification you own \u2014 a user-initiated request (from check_replies), or your OWN contact question once await_reply/check_replies returns its answer and you start acting on it. Pass that notificationId. THIS is what actually claims/acknowledges a reply or request \u2014 check_replies is a pure read that never consumes anything on its own, so call this as soon as you start engaging with something it returned; otherwise that same item just keeps reappearing forever. States: in_progress (you started working), completed (done), or needs_input (you need more from the user \u2014 usually paired with a contact carrying parentId = the same notificationId you're reporting on). Calling this reliably is also what lets a future session's check_replies surface `stalled` work you (or a crashed/idle prior session) left at in_progress without ever reporting completed.",
|
|
315
352
|
inputSchema: json(SetTaskStateToolSchema)
|
|
316
353
|
},
|
|
317
354
|
{
|
|
318
355
|
name: "schedule_callback",
|
|
319
|
-
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
|
|
356
|
+
description: "Promise the user a follow-up you'll keep even if you go idle. Use it when they ask you to report back: trigger 'on_done' (when you finish \u2014 fires when you call set_task_state completed), 'on_blocked' (if you hit a blocker \u2014 fires on set_task_state needs_input), or 'scheduled' with dueInSeconds (e.g. 'remind me in 10 min'). Pass the threadId of the conversation and a short note. Fulfill it by calling contact on that threadId; check_replies re-lists due callbacks until you do.",
|
|
320
357
|
inputSchema: json(ScheduleCallbackSchema)
|
|
321
358
|
},
|
|
322
359
|
{
|
|
@@ -324,8 +361,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
324
361
|
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 `threadId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { threadId }.",
|
|
325
362
|
inputSchema: json(HandoffSchema)
|
|
326
363
|
}
|
|
327
|
-
]
|
|
328
|
-
}
|
|
364
|
+
];
|
|
365
|
+
return { tools };
|
|
366
|
+
});
|
|
329
367
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
330
368
|
try {
|
|
331
369
|
return await handleTool(request);
|
|
@@ -423,7 +461,10 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
|
423
461
|
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;
|
|
424
462
|
return { content: [{ type: "text", text: JSON.stringify({ ...result, message }) }] };
|
|
425
463
|
}
|
|
426
|
-
// `
|
|
464
|
+
// `contact` (#575) is the one LISTED name; `notify` (#496) and `notify_user`
|
|
465
|
+
// (the original) are HIDDEN aliases — unlisted but accepted, so stale prompts,
|
|
466
|
+
// hooks, and cached 0.24 servers' habits keep working. Same handler, same wire.
|
|
467
|
+
case "contact":
|
|
427
468
|
case "notify":
|
|
428
469
|
case "notify_user": {
|
|
429
470
|
const parsed = NotifyRequestSchema.parse(request.params.arguments);
|
package/dist/listen.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import {
|
|
3
3
|
WAKE_EVENT,
|
|
4
4
|
wakeChannel
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-RXOUZ7WR.js";
|
|
6
6
|
import {
|
|
7
7
|
checkReplies,
|
|
8
8
|
registerDelivery
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-GWYHSAU5.js";
|
|
10
10
|
|
|
11
11
|
// src/listen.ts
|
|
12
12
|
import { createClient } from "@supabase/supabase-js";
|
package/dist/onboard.js
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
autoConfigureClients,
|
|
4
4
|
claudeInstallHint,
|
|
5
5
|
openBrowser
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-63VXNOEC.js";
|
|
7
7
|
import {
|
|
8
8
|
AGENT_NAME,
|
|
9
9
|
TOKEN_PATH,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
requestCode,
|
|
12
12
|
saveToken,
|
|
13
13
|
sleep
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-GWYHSAU5.js";
|
|
15
15
|
|
|
16
16
|
// src/onboard.ts
|
|
17
17
|
async function main() {
|
package/dist/statusline.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paigy/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Paigy MCP server
|
|
3
|
+
"version": "0.25.1",
|
|
4
|
+
"description": "Paigy MCP server \u2014 a voice inbox for your AI agents. Lets an agent notify a user and await their reply.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
"url": "git+https://github.com/mauurda/paigy.git",
|
|
23
23
|
"directory": "apps/mcp"
|
|
24
24
|
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"dev": "tsup --watch",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"prepublishOnly": "pnpm --filter @paigy/schema build && pnpm --filter @paigy/crypto build && pnpm --filter @paigy/sdk build && pnpm build"
|
|
31
|
+
},
|
|
25
32
|
"dependencies": {
|
|
26
33
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
27
34
|
"@supabase/supabase-js": "^2.47.10",
|
|
@@ -31,19 +38,13 @@
|
|
|
31
38
|
"zod-to-json-schema": "^3.24.1"
|
|
32
39
|
},
|
|
33
40
|
"devDependencies": {
|
|
41
|
+
"@paigy/crypto": "workspace:*",
|
|
42
|
+
"@paigy/schema": "workspace:*",
|
|
43
|
+
"@paigy/sdk": "workspace:*",
|
|
34
44
|
"@types/node": "^22.0.0",
|
|
35
45
|
"@types/qrcode-generator": "^1.0.6",
|
|
36
46
|
"tsup": "^8.3.5",
|
|
37
47
|
"typescript": "^5.7.2",
|
|
38
|
-
"vitest": "^2.1.8"
|
|
39
|
-
"@paigy/schema": "0.0.0",
|
|
40
|
-
"@paigy/sdk": "0.1.0",
|
|
41
|
-
"@paigy/crypto": "0.0.0"
|
|
42
|
-
},
|
|
43
|
-
"scripts": {
|
|
44
|
-
"build": "tsup",
|
|
45
|
-
"dev": "tsup --watch",
|
|
46
|
-
"typecheck": "tsc --noEmit",
|
|
47
|
-
"test": "vitest run"
|
|
48
|
+
"vitest": "^2.1.8"
|
|
48
49
|
}
|
|
49
|
-
}
|
|
50
|
+
}
|