@paigy/mcp 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -8
- package/dist/{chunk-KAEJRPN5.js → chunk-FRYGTKLM.js} +78 -30
- package/dist/{chunk-B7SCZUYX.js → chunk-TE57PAKM.js} +122 -42
- package/dist/{chunk-BZPE2ZFE.js → chunk-XQVQ4JRT.js} +1 -1
- package/dist/index.js +75 -28
- package/dist/listen.js +56 -3
- package/dist/onboard.js +2 -2
- package/dist/statusline.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @paigy/mcp
|
|
2
2
|
|
|
3
|
+
> **Canonical setup:** `curl -fsSL https://paigy.ai/install | sh` — one command, one
|
|
4
|
+
> QR scan; no pairing codes. Everything below is the manual per-client reference for
|
|
5
|
+
> machines that can't run the harness.
|
|
6
|
+
|
|
3
7
|
A voice inbox for your AI agents. This MCP server lets an agent **notify a user** and **await their reply** — so a long-running agent can ask a question, hand off, and resume on the answer. It's a thin MCP-tool wrapper over `@paigy/sdk` (`packages/sdk`) — use the SDK directly from any Node process that isn't an MCP client.
|
|
4
8
|
|
|
5
9
|
## Install
|
|
@@ -141,22 +145,51 @@ wake channel and sweeps on every nudge. Keep it alive past the terminal with
|
|
|
141
145
|
removes it).
|
|
142
146
|
|
|
143
147
|
To launch an agent — any agent, not just Claude — when work arrives, set
|
|
144
|
-
`PAIGY_ON_WAKE` to a command before `--install`. The
|
|
145
|
-
work
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
`PAIGY_ON_WAKE` to a command before `--install`. The sweep has already claimed the
|
|
149
|
+
work, so the launcher reads it from the environment rather than calling
|
|
150
|
+
`check_replies` again:
|
|
151
|
+
|
|
152
|
+
| Variable | What it holds |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| `PAIGY_WORK` | the whole swept payload as JSON (`replies`, `requests`, `owedCallbacks`, `stalled`, `threads`) |
|
|
155
|
+
| `PAIGY_EVENT` | the wake that caused this run — `boot`, `wake:reply`, `cron:callback`… |
|
|
156
|
+
| `PAIGY_THREAD_ID` | the thread to continue on |
|
|
157
|
+
| `PAIGY_NOTIFICATION_ID` | the notification being answered or acted on (unset for an owed callback) |
|
|
158
|
+
| `PAIGY_CONTEXT_THREAD_ID` | a past conversation the user seeded this with — `get_thread` it **first** |
|
|
159
|
+
| `PAIGY_TEXT` | what the user said (or the callback note you owe them), in prose |
|
|
160
|
+
|
|
161
|
+
Hand `$PAIGY_WORK` to a harness that can read JSON and decide for itself; use the
|
|
162
|
+
scalars for a plain shell launcher that shouldn't need `jq`. They describe **one**
|
|
163
|
+
item — the oldest thread without a turn already in flight — because the queue rail's
|
|
164
|
+
contract is one thread at a time. An absent fact is unset rather than empty, so
|
|
165
|
+
`${PAIGY_CONTEXT_THREAD_ID:-}` distinguishes "no seed" from "seeded with nothing".
|
|
149
166
|
|
|
150
167
|
```sh
|
|
151
|
-
# Claude Code:
|
|
168
|
+
# Claude Code — hand it everything and let it plan:
|
|
152
169
|
PAIGY_ON_WAKE='claude -p "Handle the Paigy work in $PAIGY_WORK — rehydrate threads you do not recognize via get_thread first."' \
|
|
153
170
|
npx -y -p @paigy/mcp paigy-listen --install
|
|
154
171
|
|
|
155
|
-
# Codex (or any CLI harness)
|
|
156
|
-
PAIGY_ON_WAKE='codex exec "
|
|
172
|
+
# Codex (or any CLI harness) — the scalars are enough for a one-liner:
|
|
173
|
+
PAIGY_ON_WAKE='codex exec "Continue Paigy thread $PAIGY_THREAD_ID. The user said: $PAIGY_TEXT. Call get_thread on it first if you do not recognize it, then reply with contact."' \
|
|
157
174
|
npx -y -p @paigy/mcp paigy-listen --install
|
|
158
175
|
```
|
|
159
176
|
|
|
177
|
+
`$PAIGY_TEXT` is whatever the user said, expanded inside a shell command — keep it
|
|
178
|
+
quoted, as above. For anything longer than a one-liner, point `PAIGY_ON_WAKE` at a
|
|
179
|
+
script and branch on `$PAIGY_EVENT` there:
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
#!/bin/sh
|
|
183
|
+
# ~/.paigy/on-wake.sh — chmod +x, then PAIGY_ON_WAKE=~/.paigy/on-wake.sh
|
|
184
|
+
seed=""
|
|
185
|
+
[ -n "${PAIGY_CONTEXT_THREAD_ID:-}" ] && seed="It continues thread $PAIGY_CONTEXT_THREAD_ID — get_thread that first."
|
|
186
|
+
|
|
187
|
+
case "$PAIGY_EVENT" in
|
|
188
|
+
cron:callback*) codex exec "You owe the user a callback on thread $PAIGY_THREAD_ID: $PAIGY_TEXT. Deliver it with contact." ;;
|
|
189
|
+
*) codex exec "Paigy thread $PAIGY_THREAD_ID. The user said: $PAIGY_TEXT. $seed Reply with contact when done." ;;
|
|
190
|
+
esac
|
|
191
|
+
```
|
|
192
|
+
|
|
160
193
|
## Statusline (Claude Code)
|
|
161
194
|
|
|
162
195
|
`paigy-statusline` prints a one-line connection status — the account's session
|
|
@@ -20,7 +20,7 @@ var TransformSchema = z.enum([
|
|
|
20
20
|
"coalesce",
|
|
21
21
|
// many bundles → one — morning triage (#347), threading-supersede, digest
|
|
22
22
|
"organize",
|
|
23
|
-
// group related bundles onto one thread — threading (`
|
|
23
|
+
// group related bundles onto one thread — threading (`parentId`), parent/clarify links
|
|
24
24
|
"summarize"
|
|
25
25
|
// reduce volume, keep decision value — 30-turn cap, spoken briefing
|
|
26
26
|
]);
|
|
@@ -90,13 +90,19 @@ var NotifyRequestSchema = z.object({
|
|
|
90
90
|
repo: z.string().optional(),
|
|
91
91
|
/** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
|
|
92
92
|
branch: z.string().optional(),
|
|
93
|
-
/** Continue an existing conversation
|
|
94
|
-
|
|
93
|
+
/** Continue an existing conversation — the id of any notification in it (its root
|
|
94
|
+
* is the conversation's identity). Omitted = start a new conversation. Renamed
|
|
95
|
+
* from `parentId` (2026-08-03): one linkage system, the parent; the API edge
|
|
96
|
+
* still accepts the old name from older clients. */
|
|
97
|
+
parentId: z.string().uuid().optional(),
|
|
95
98
|
urgency: NotifyLevelSchema.default("inbox").describe(
|
|
96
99
|
"The level you're requesting \u2014 the user's account permissions + session mode can lower it. 'inbox' (default) = sits silently in the inbox for the user to get to. 'push' = a quiet passive push (lands in Notification Center, no sound) \u2014 a gentle heads-up. 'banner' = a time-sensitive banner/lock-screen push with sound (a 'paige') they tap to open \u2014 use when you need them soon-ish but it's not worth ringing them. 'call' = rings the user's phone now (a CallKit voice call) \u2014 use only when you genuinely need them in the moment (blocked and waiting, time-sensitive). context.title is what they see on the banner/ring, so make it specific."
|
|
97
100
|
),
|
|
98
|
-
/** The request this one
|
|
99
|
-
parentId
|
|
101
|
+
/** The request this one CLARIFIES — spawning a clarification keeps that parent
|
|
102
|
+
* visible and marks it needs_input. Renamed from the old `parentId` (2026-08-03)
|
|
103
|
+
* when `parentId` became the conversation handle: `parentId` says WHERE, this
|
|
104
|
+
* says HOW. */
|
|
105
|
+
clarifies: z.string().optional(),
|
|
100
106
|
/** E2EE (text lane): when the pairing is E2EE, the sealed replacements for the
|
|
101
107
|
* plaintext content fields, keyed by field name. FINALIZED wire shape (was
|
|
102
108
|
* provisional in the storage PR): a per-field map `{ context?, options?,
|
|
@@ -212,7 +218,11 @@ var UserAnswerSchema = z.discriminatedUnion("kind", [
|
|
|
212
218
|
z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
|
|
213
219
|
]);
|
|
214
220
|
var IntentSchema = z.object({
|
|
215
|
-
|
|
221
|
+
// The full vocabulary the bot's mapper emits (mapper.INTENT_KINDS) — the schema lagged
|
|
222
|
+
// it by two ("detail", "feedback"), and because the settle handler parsed the array
|
|
223
|
+
// all-or-nothing, ONE feedback act silently dropped EVERY intent on the call,
|
|
224
|
+
// questions included. Found auditing five calls' stored feedback, 2026-08-01.
|
|
225
|
+
kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command", "control"]),
|
|
216
226
|
detail: z.string(),
|
|
217
227
|
/** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
|
|
218
228
|
* "after lunch") against the agent machine's clock — the user's — and attaches
|
|
@@ -223,7 +233,7 @@ var IntentSchema = z.object({
|
|
|
223
233
|
var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
224
234
|
z.object({
|
|
225
235
|
type: z.literal("reply"),
|
|
226
|
-
|
|
236
|
+
parentId: z.string(),
|
|
227
237
|
notificationId: z.string(),
|
|
228
238
|
answer: UserAnswerSchema,
|
|
229
239
|
/** E2EE: present when the answer is sealed. The server relays the opaque answer
|
|
@@ -244,7 +254,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
244
254
|
}),
|
|
245
255
|
z.object({
|
|
246
256
|
type: z.literal("remind"),
|
|
247
|
-
|
|
257
|
+
parentId: z.string(),
|
|
248
258
|
notificationId: z.string(),
|
|
249
259
|
remindAt: z.string().datetime({ offset: true }),
|
|
250
260
|
/** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
|
|
@@ -256,7 +266,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
256
266
|
* re-orient via get_thread / check_replies). */
|
|
257
267
|
z.object({
|
|
258
268
|
type: z.literal("superseded"),
|
|
259
|
-
|
|
269
|
+
parentId: z.string(),
|
|
260
270
|
notificationId: z.string()
|
|
261
271
|
}),
|
|
262
272
|
/** A LIVE call's turn, streamed as it lands (#783). PROVISIONAL: the user can still
|
|
@@ -279,7 +289,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
279
289
|
]);
|
|
280
290
|
var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
|
|
281
291
|
var ScheduleCallbackSchema = z.object({
|
|
282
|
-
|
|
292
|
+
parentId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
|
|
283
293
|
trigger: CallbackTriggerSchema,
|
|
284
294
|
dueInSeconds: z.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
|
|
285
295
|
note: z.string().optional().describe("What to tell the user when you follow up.")
|
|
@@ -287,7 +297,7 @@ var ScheduleCallbackSchema = z.object({
|
|
|
287
297
|
var PendingRepliesSchema = z.object({
|
|
288
298
|
replies: z.array(
|
|
289
299
|
z.object({
|
|
290
|
-
|
|
300
|
+
parentId: z.string(),
|
|
291
301
|
notificationId: z.string(),
|
|
292
302
|
answer: UserAnswerSchema,
|
|
293
303
|
/** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the
|
|
@@ -304,33 +314,33 @@ var PendingRepliesSchema = z.object({
|
|
|
304
314
|
})
|
|
305
315
|
),
|
|
306
316
|
pending: z.array(
|
|
307
|
-
z.object({
|
|
317
|
+
z.object({ parentId: z.string(), notificationId: z.string(), createdAt: z.string() })
|
|
308
318
|
),
|
|
309
319
|
/** User-initiated requests addressed to this agent; act on them and reply via
|
|
310
|
-
* contact on the same
|
|
320
|
+
* contact on the same parentId. Keeps reappearing until you call
|
|
311
321
|
* set_task_state on its notificationId. */
|
|
312
322
|
requests: z.array(
|
|
313
323
|
z.object({
|
|
314
|
-
|
|
324
|
+
parentId: z.string(),
|
|
315
325
|
notificationId: z.string(),
|
|
316
326
|
text: z.string(),
|
|
317
327
|
createdAt: z.string(),
|
|
318
328
|
/** The user seeded this request with a past conversation — call get_thread on it
|
|
319
329
|
* FIRST and treat the transcript as prior context (#57/#251). */
|
|
320
|
-
|
|
330
|
+
contextParentId: z.string().optional()
|
|
321
331
|
})
|
|
322
332
|
),
|
|
323
333
|
/** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
|
|
324
334
|
* if blocked, or at a time that has passed). Re-surfaced every sweep until you
|
|
325
|
-
* fulfill one by calling contact on its
|
|
335
|
+
* fulfill one by calling contact on its parentId. */
|
|
326
336
|
owedCallbacks: z.array(
|
|
327
|
-
z.object({
|
|
337
|
+
z.object({ parentId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
|
|
328
338
|
),
|
|
329
339
|
/** Work (either direction) you reported in_progress a while ago and never reported
|
|
330
340
|
* completed — likely left half-done by this session or a prior one that crashed or
|
|
331
341
|
* went idle. Report a real state (set_task_state) or continue the work. */
|
|
332
342
|
stalled: z.array(
|
|
333
|
-
z.object({
|
|
343
|
+
z.object({ parentId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
|
|
334
344
|
),
|
|
335
345
|
/** The queue rail (#614, pending/design.md): the same replies + requests, grouped by
|
|
336
346
|
* thread and ordered oldest-thread-first, so you work ONE thread at a time — fold all of
|
|
@@ -341,7 +351,7 @@ var PendingRepliesSchema = z.object({
|
|
|
341
351
|
* notificationId). Derived, never stored — a crashed agent recomputes it exactly. */
|
|
342
352
|
threads: z.array(
|
|
343
353
|
z.object({
|
|
344
|
-
|
|
354
|
+
parentId: z.string(),
|
|
345
355
|
busy: z.boolean(),
|
|
346
356
|
items: z.array(
|
|
347
357
|
z.object({
|
|
@@ -382,14 +392,33 @@ var AgendaTurnSchema = z.object({
|
|
|
382
392
|
question: z.string().min(1).nullable(),
|
|
383
393
|
/** True on the one turn carrying the agent's own declared question. */
|
|
384
394
|
asks: z.boolean().optional(),
|
|
395
|
+
/** The claim this turn belongs to (#781) — the RETURN identity: answers route by it.
|
|
396
|
+
* Absent on a single-claim plan (the session's own claim) and on shared context turns,
|
|
397
|
+
* which route nothing. */
|
|
398
|
+
claimId: z.string().optional(),
|
|
399
|
+
/** The claim's voice key (#462) — the OUTBOUND identity, audible who-is-asking. */
|
|
400
|
+
voice: z.string().optional(),
|
|
385
401
|
select: SelectShapeSchema.optional(),
|
|
386
|
-
options: z.array(OptionSchema.omit({ id: true })).optional()
|
|
402
|
+
options: z.array(OptionSchema.omit({ id: true })).optional(),
|
|
403
|
+
/** The seat was satisfied by the call's own ACCOUNT (replan-design.md, user_info): the
|
|
404
|
+
* caller already answered this claim in an earlier utterance, quoted here VERBATIM —
|
|
405
|
+
* the bot speaks the turn's short confirmation, posts these words as the claim's
|
|
406
|
+
* answer, and never re-asks. Grounded at parse time: an invented settle is #796. */
|
|
407
|
+
settle: z.string().optional(),
|
|
408
|
+
/** Pacing (#826, owner 2026-08-03: "how fast we move through them ... are parameters"):
|
|
409
|
+
* seconds the floor stays open after this turn speaks. Absent = the bot's defaults
|
|
410
|
+
* (the beat for context, the answer window for asks). Clamped bot-side. */
|
|
411
|
+
pace: z.number().positive().optional(),
|
|
412
|
+
/** Whether the walk WAITS for an answer before moving on. Absent = derived as today
|
|
413
|
+
* (a question blocks, context flows). blocking:false on a question = ask and move
|
|
414
|
+
* on, the claim stays pending; blocking:true on context = hold for a reply. */
|
|
415
|
+
blocking: z.boolean().optional()
|
|
387
416
|
});
|
|
388
417
|
var InboxItemSchema = z.object({
|
|
389
418
|
id: z.string(),
|
|
390
419
|
/** The conversation thread + connection this item lives on. Present on the replied
|
|
391
420
|
* detail — they power History's "Continue" / "New session from this" (#57/#251). */
|
|
392
|
-
|
|
421
|
+
parentId: z.string().optional(),
|
|
393
422
|
tokenId: z.string().optional(),
|
|
394
423
|
status: NotifyStatusSchema,
|
|
395
424
|
context: ContextSchema,
|
|
@@ -423,7 +452,7 @@ var InboxItemSchema = z.object({
|
|
|
423
452
|
* the agent (provider-agnostic; set server-side). Absent = no hard error, though the
|
|
424
453
|
* client may still flag a stall by age. Drives the inbox error badge + Retry. */
|
|
425
454
|
error: z.string().optional(),
|
|
426
|
-
|
|
455
|
+
clarifies: z.string().optional(),
|
|
427
456
|
select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
|
|
428
457
|
confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
|
|
429
458
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
@@ -522,7 +551,7 @@ var UserSettingsSchema = z.object({
|
|
|
522
551
|
});
|
|
523
552
|
var HistoryItemSchema = z.object({
|
|
524
553
|
id: z.string(),
|
|
525
|
-
|
|
554
|
+
parentId: z.string(),
|
|
526
555
|
/** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
|
|
527
556
|
initiator: z.enum(["user", "agent"]),
|
|
528
557
|
title: z.string(),
|
|
@@ -549,6 +578,16 @@ var ConnectionSummarySchema = z.object({
|
|
|
549
578
|
/** Most recent notification on this connection, either direction. Null = no contact yet.
|
|
550
579
|
* Drives the agents-page recency grouping (Today / This week / …). */
|
|
551
580
|
lastContactAt: z.string().datetime().nullable(),
|
|
581
|
+
/** Last presence heartbeat from a running agent process (POST /api/presence) — the
|
|
582
|
+
* desktop app while open. Null = never seen; stale = offline. */
|
|
583
|
+
lastSeenAt: z.string().datetime().nullable().optional(),
|
|
584
|
+
/** What a live desktop can run (companion.md §2.2), advertised on its heartbeat:
|
|
585
|
+
* harness availabilities + granted workspaces — the option set the phone's
|
|
586
|
+
* "new session" sheet offers. Absent for ordinary MCP agents. */
|
|
587
|
+
runtime: z.object({
|
|
588
|
+
harnesses: z.array(z.object({ name: z.string(), label: z.string(), status: z.string() })).optional(),
|
|
589
|
+
workspaces: z.array(z.string()).optional()
|
|
590
|
+
}).optional(),
|
|
552
591
|
/** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
|
|
553
592
|
* false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
|
|
554
593
|
managed: z.boolean()
|
|
@@ -560,15 +599,15 @@ var CreateRequestSchema = z.object({
|
|
|
560
599
|
text: z.string().min(1),
|
|
561
600
|
/** Land the request on an existing conversation thread (History → "Continue")
|
|
562
601
|
* instead of minting a fresh one. Must belong to the requesting user. */
|
|
563
|
-
|
|
602
|
+
parentId: z.string().optional(),
|
|
564
603
|
/** Point the agent at a past conversation (possibly with a different agent) as
|
|
565
604
|
* starting context (History → "New session from this"). A reference, not a copy —
|
|
566
605
|
* the agent reads it via get_thread. Must belong to the requesting user. */
|
|
567
|
-
|
|
606
|
+
contextParentId: z.string().optional()
|
|
568
607
|
});
|
|
569
608
|
var HandoffSchema = z.object({
|
|
570
609
|
/** Land the note on an existing thread; omitted mints a fresh one. */
|
|
571
|
-
|
|
610
|
+
parentId: z.string().uuid().optional(),
|
|
572
611
|
/** One-line headline of the working context handed off. */
|
|
573
612
|
title: z.string().min(1),
|
|
574
613
|
/** The brief — standalone notes the successor reads (what was done, what's left, links). */
|
|
@@ -607,7 +646,7 @@ var NoteSchema = z.object({
|
|
|
607
646
|
/** Who it was assigned to (a participant ref, 'agent:<tokenId>'); null = unassigned. */
|
|
608
647
|
assignee: z.string().nullable(),
|
|
609
648
|
/** The request thread minted at assignment; null until assigned. */
|
|
610
|
-
|
|
649
|
+
parentId: z.string().nullable(),
|
|
611
650
|
createdAt: z.string()
|
|
612
651
|
});
|
|
613
652
|
var CreateNoteSchema = z.object({
|
|
@@ -623,8 +662,17 @@ var RecordDecisionSchema = z.object({
|
|
|
623
662
|
answer: z.string().min(1).max(2e3)
|
|
624
663
|
}).refine((d) => d.decisionId || d.question, { message: "decisionId or question required" });
|
|
625
664
|
var AssignNoteSchema = z.object({
|
|
626
|
-
|
|
627
|
-
|
|
665
|
+
/** An EXISTING agent: token id or nickname. Omit when spawning fresh. */
|
|
666
|
+
target: z.string().min(1).optional(),
|
|
667
|
+
/** Spawn a NEW session for this note (companion.md §2.2): the assignee doesn't
|
|
668
|
+
* exist yet — mint it on a live desktop that advertises the harness+workspace,
|
|
669
|
+
* named after the note. The brief arrives as its opening request. */
|
|
670
|
+
spawn: z.object({
|
|
671
|
+
hostTokenId: z.string().uuid(),
|
|
672
|
+
harness: z.string().min(1),
|
|
673
|
+
workspace: z.string().min(1)
|
|
674
|
+
}).optional()
|
|
675
|
+
}).refine((a) => !!a.target !== !!a.spawn, { message: "exactly one of target or spawn" });
|
|
628
676
|
var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
|
|
629
677
|
var WAKE_EVENT = "wake";
|
|
630
678
|
var wakeChannel = (tokenId) => `wake:${tokenId}`;
|
|
@@ -686,7 +734,7 @@ var DeviceRosterSchema = z.object({
|
|
|
686
734
|
var WakeNudgeSchema = z.object({
|
|
687
735
|
kind: z.enum(["reply", "request", "callback"]),
|
|
688
736
|
notificationId: z.string().optional(),
|
|
689
|
-
|
|
737
|
+
parentId: z.string()
|
|
690
738
|
});
|
|
691
739
|
var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
|
|
692
740
|
var PairingRevealSchema = z.object({
|
|
@@ -2336,7 +2336,7 @@ var TransformSchema = z.enum([
|
|
|
2336
2336
|
"coalesce",
|
|
2337
2337
|
// many bundles → one — morning triage (#347), threading-supersede, digest
|
|
2338
2338
|
"organize",
|
|
2339
|
-
// group related bundles onto one thread — threading (`
|
|
2339
|
+
// group related bundles onto one thread — threading (`parentId`), parent/clarify links
|
|
2340
2340
|
"summarize"
|
|
2341
2341
|
// reduce volume, keep decision value — 30-turn cap, spoken briefing
|
|
2342
2342
|
]);
|
|
@@ -2406,13 +2406,19 @@ var NotifyRequestSchema = z.object({
|
|
|
2406
2406
|
repo: z.string().optional(),
|
|
2407
2407
|
/** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
|
|
2408
2408
|
branch: z.string().optional(),
|
|
2409
|
-
/** Continue an existing conversation
|
|
2410
|
-
|
|
2409
|
+
/** Continue an existing conversation — the id of any notification in it (its root
|
|
2410
|
+
* is the conversation's identity). Omitted = start a new conversation. Renamed
|
|
2411
|
+
* from `parentId` (2026-08-03): one linkage system, the parent; the API edge
|
|
2412
|
+
* still accepts the old name from older clients. */
|
|
2413
|
+
parentId: z.string().uuid().optional(),
|
|
2411
2414
|
urgency: NotifyLevelSchema.default("inbox").describe(
|
|
2412
2415
|
"The level you're requesting \u2014 the user's account permissions + session mode can lower it. 'inbox' (default) = sits silently in the inbox for the user to get to. 'push' = a quiet passive push (lands in Notification Center, no sound) \u2014 a gentle heads-up. 'banner' = a time-sensitive banner/lock-screen push with sound (a 'paige') they tap to open \u2014 use when you need them soon-ish but it's not worth ringing them. 'call' = rings the user's phone now (a CallKit voice call) \u2014 use only when you genuinely need them in the moment (blocked and waiting, time-sensitive). context.title is what they see on the banner/ring, so make it specific."
|
|
2413
2416
|
),
|
|
2414
|
-
/** The request this one
|
|
2415
|
-
parentId
|
|
2417
|
+
/** The request this one CLARIFIES — spawning a clarification keeps that parent
|
|
2418
|
+
* visible and marks it needs_input. Renamed from the old `parentId` (2026-08-03)
|
|
2419
|
+
* when `parentId` became the conversation handle: `parentId` says WHERE, this
|
|
2420
|
+
* says HOW. */
|
|
2421
|
+
clarifies: z.string().optional(),
|
|
2416
2422
|
/** E2EE (text lane): when the pairing is E2EE, the sealed replacements for the
|
|
2417
2423
|
* plaintext content fields, keyed by field name. FINALIZED wire shape (was
|
|
2418
2424
|
* provisional in the storage PR): a per-field map `{ context?, options?,
|
|
@@ -2573,7 +2579,11 @@ var UserAnswerSchema = z.discriminatedUnion("kind", [
|
|
|
2573
2579
|
z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
|
|
2574
2580
|
]);
|
|
2575
2581
|
var IntentSchema = z.object({
|
|
2576
|
-
|
|
2582
|
+
// The full vocabulary the bot's mapper emits (mapper.INTENT_KINDS) — the schema lagged
|
|
2583
|
+
// it by two ("detail", "feedback"), and because the settle handler parsed the array
|
|
2584
|
+
// all-or-nothing, ONE feedback act silently dropped EVERY intent on the call,
|
|
2585
|
+
// questions included. Found auditing five calls' stored feedback, 2026-08-01.
|
|
2586
|
+
kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command", "control"]),
|
|
2577
2587
|
detail: z.string(),
|
|
2578
2588
|
/** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
|
|
2579
2589
|
* "after lunch") against the agent machine's clock — the user's — and attaches
|
|
@@ -2584,7 +2594,7 @@ var IntentSchema = z.object({
|
|
|
2584
2594
|
var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
2585
2595
|
z.object({
|
|
2586
2596
|
type: z.literal("reply"),
|
|
2587
|
-
|
|
2597
|
+
parentId: z.string(),
|
|
2588
2598
|
notificationId: z.string(),
|
|
2589
2599
|
answer: UserAnswerSchema,
|
|
2590
2600
|
/** E2EE: present when the answer is sealed. The server relays the opaque answer
|
|
@@ -2605,7 +2615,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
2605
2615
|
}),
|
|
2606
2616
|
z.object({
|
|
2607
2617
|
type: z.literal("remind"),
|
|
2608
|
-
|
|
2618
|
+
parentId: z.string(),
|
|
2609
2619
|
notificationId: z.string(),
|
|
2610
2620
|
remindAt: z.string().datetime({ offset: true }),
|
|
2611
2621
|
/** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
|
|
@@ -2617,7 +2627,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
2617
2627
|
* re-orient via get_thread / check_replies). */
|
|
2618
2628
|
z.object({
|
|
2619
2629
|
type: z.literal("superseded"),
|
|
2620
|
-
|
|
2630
|
+
parentId: z.string(),
|
|
2621
2631
|
notificationId: z.string()
|
|
2622
2632
|
}),
|
|
2623
2633
|
/** A LIVE call's turn, streamed as it lands (#783). PROVISIONAL: the user can still
|
|
@@ -2640,7 +2650,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
2640
2650
|
]);
|
|
2641
2651
|
var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
|
|
2642
2652
|
var ScheduleCallbackSchema = z.object({
|
|
2643
|
-
|
|
2653
|
+
parentId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
|
|
2644
2654
|
trigger: CallbackTriggerSchema,
|
|
2645
2655
|
dueInSeconds: z.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
|
|
2646
2656
|
note: z.string().optional().describe("What to tell the user when you follow up.")
|
|
@@ -2648,7 +2658,7 @@ var ScheduleCallbackSchema = z.object({
|
|
|
2648
2658
|
var PendingRepliesSchema = z.object({
|
|
2649
2659
|
replies: z.array(
|
|
2650
2660
|
z.object({
|
|
2651
|
-
|
|
2661
|
+
parentId: z.string(),
|
|
2652
2662
|
notificationId: z.string(),
|
|
2653
2663
|
answer: UserAnswerSchema,
|
|
2654
2664
|
/** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the
|
|
@@ -2665,33 +2675,33 @@ var PendingRepliesSchema = z.object({
|
|
|
2665
2675
|
})
|
|
2666
2676
|
),
|
|
2667
2677
|
pending: z.array(
|
|
2668
|
-
z.object({
|
|
2678
|
+
z.object({ parentId: z.string(), notificationId: z.string(), createdAt: z.string() })
|
|
2669
2679
|
),
|
|
2670
2680
|
/** User-initiated requests addressed to this agent; act on them and reply via
|
|
2671
|
-
* contact on the same
|
|
2681
|
+
* contact on the same parentId. Keeps reappearing until you call
|
|
2672
2682
|
* set_task_state on its notificationId. */
|
|
2673
2683
|
requests: z.array(
|
|
2674
2684
|
z.object({
|
|
2675
|
-
|
|
2685
|
+
parentId: z.string(),
|
|
2676
2686
|
notificationId: z.string(),
|
|
2677
2687
|
text: z.string(),
|
|
2678
2688
|
createdAt: z.string(),
|
|
2679
2689
|
/** The user seeded this request with a past conversation — call get_thread on it
|
|
2680
2690
|
* FIRST and treat the transcript as prior context (#57/#251). */
|
|
2681
|
-
|
|
2691
|
+
contextParentId: z.string().optional()
|
|
2682
2692
|
})
|
|
2683
2693
|
),
|
|
2684
2694
|
/** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
|
|
2685
2695
|
* if blocked, or at a time that has passed). Re-surfaced every sweep until you
|
|
2686
|
-
* fulfill one by calling contact on its
|
|
2696
|
+
* fulfill one by calling contact on its parentId. */
|
|
2687
2697
|
owedCallbacks: z.array(
|
|
2688
|
-
z.object({
|
|
2698
|
+
z.object({ parentId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
|
|
2689
2699
|
),
|
|
2690
2700
|
/** Work (either direction) you reported in_progress a while ago and never reported
|
|
2691
2701
|
* completed — likely left half-done by this session or a prior one that crashed or
|
|
2692
2702
|
* went idle. Report a real state (set_task_state) or continue the work. */
|
|
2693
2703
|
stalled: z.array(
|
|
2694
|
-
z.object({
|
|
2704
|
+
z.object({ parentId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
|
|
2695
2705
|
),
|
|
2696
2706
|
/** The queue rail (#614, pending/design.md): the same replies + requests, grouped by
|
|
2697
2707
|
* thread and ordered oldest-thread-first, so you work ONE thread at a time — fold all of
|
|
@@ -2702,7 +2712,7 @@ var PendingRepliesSchema = z.object({
|
|
|
2702
2712
|
* notificationId). Derived, never stored — a crashed agent recomputes it exactly. */
|
|
2703
2713
|
threads: z.array(
|
|
2704
2714
|
z.object({
|
|
2705
|
-
|
|
2715
|
+
parentId: z.string(),
|
|
2706
2716
|
busy: z.boolean(),
|
|
2707
2717
|
items: z.array(
|
|
2708
2718
|
z.object({
|
|
@@ -2743,14 +2753,33 @@ var AgendaTurnSchema = z.object({
|
|
|
2743
2753
|
question: z.string().min(1).nullable(),
|
|
2744
2754
|
/** True on the one turn carrying the agent's own declared question. */
|
|
2745
2755
|
asks: z.boolean().optional(),
|
|
2756
|
+
/** The claim this turn belongs to (#781) — the RETURN identity: answers route by it.
|
|
2757
|
+
* Absent on a single-claim plan (the session's own claim) and on shared context turns,
|
|
2758
|
+
* which route nothing. */
|
|
2759
|
+
claimId: z.string().optional(),
|
|
2760
|
+
/** The claim's voice key (#462) — the OUTBOUND identity, audible who-is-asking. */
|
|
2761
|
+
voice: z.string().optional(),
|
|
2746
2762
|
select: SelectShapeSchema.optional(),
|
|
2747
|
-
options: z.array(OptionSchema.omit({ id: true })).optional()
|
|
2763
|
+
options: z.array(OptionSchema.omit({ id: true })).optional(),
|
|
2764
|
+
/** The seat was satisfied by the call's own ACCOUNT (replan-design.md, user_info): the
|
|
2765
|
+
* caller already answered this claim in an earlier utterance, quoted here VERBATIM —
|
|
2766
|
+
* the bot speaks the turn's short confirmation, posts these words as the claim's
|
|
2767
|
+
* answer, and never re-asks. Grounded at parse time: an invented settle is #796. */
|
|
2768
|
+
settle: z.string().optional(),
|
|
2769
|
+
/** Pacing (#826, owner 2026-08-03: "how fast we move through them ... are parameters"):
|
|
2770
|
+
* seconds the floor stays open after this turn speaks. Absent = the bot's defaults
|
|
2771
|
+
* (the beat for context, the answer window for asks). Clamped bot-side. */
|
|
2772
|
+
pace: z.number().positive().optional(),
|
|
2773
|
+
/** Whether the walk WAITS for an answer before moving on. Absent = derived as today
|
|
2774
|
+
* (a question blocks, context flows). blocking:false on a question = ask and move
|
|
2775
|
+
* on, the claim stays pending; blocking:true on context = hold for a reply. */
|
|
2776
|
+
blocking: z.boolean().optional()
|
|
2748
2777
|
});
|
|
2749
2778
|
var InboxItemSchema = z.object({
|
|
2750
2779
|
id: z.string(),
|
|
2751
2780
|
/** The conversation thread + connection this item lives on. Present on the replied
|
|
2752
2781
|
* detail — they power History's "Continue" / "New session from this" (#57/#251). */
|
|
2753
|
-
|
|
2782
|
+
parentId: z.string().optional(),
|
|
2754
2783
|
tokenId: z.string().optional(),
|
|
2755
2784
|
status: NotifyStatusSchema,
|
|
2756
2785
|
context: ContextSchema,
|
|
@@ -2784,7 +2813,7 @@ var InboxItemSchema = z.object({
|
|
|
2784
2813
|
* the agent (provider-agnostic; set server-side). Absent = no hard error, though the
|
|
2785
2814
|
* client may still flag a stall by age. Drives the inbox error badge + Retry. */
|
|
2786
2815
|
error: z.string().optional(),
|
|
2787
|
-
|
|
2816
|
+
clarifies: z.string().optional(),
|
|
2788
2817
|
select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
|
|
2789
2818
|
confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
|
|
2790
2819
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
@@ -2883,7 +2912,7 @@ var UserSettingsSchema = z.object({
|
|
|
2883
2912
|
});
|
|
2884
2913
|
var HistoryItemSchema = z.object({
|
|
2885
2914
|
id: z.string(),
|
|
2886
|
-
|
|
2915
|
+
parentId: z.string(),
|
|
2887
2916
|
/** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
|
|
2888
2917
|
initiator: z.enum(["user", "agent"]),
|
|
2889
2918
|
title: z.string(),
|
|
@@ -2910,6 +2939,16 @@ var ConnectionSummarySchema = z.object({
|
|
|
2910
2939
|
/** Most recent notification on this connection, either direction. Null = no contact yet.
|
|
2911
2940
|
* Drives the agents-page recency grouping (Today / This week / …). */
|
|
2912
2941
|
lastContactAt: z.string().datetime().nullable(),
|
|
2942
|
+
/** Last presence heartbeat from a running agent process (POST /api/presence) — the
|
|
2943
|
+
* desktop app while open. Null = never seen; stale = offline. */
|
|
2944
|
+
lastSeenAt: z.string().datetime().nullable().optional(),
|
|
2945
|
+
/** What a live desktop can run (companion.md §2.2), advertised on its heartbeat:
|
|
2946
|
+
* harness availabilities + granted workspaces — the option set the phone's
|
|
2947
|
+
* "new session" sheet offers. Absent for ordinary MCP agents. */
|
|
2948
|
+
runtime: z.object({
|
|
2949
|
+
harnesses: z.array(z.object({ name: z.string(), label: z.string(), status: z.string() })).optional(),
|
|
2950
|
+
workspaces: z.array(z.string()).optional()
|
|
2951
|
+
}).optional(),
|
|
2913
2952
|
/** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
|
|
2914
2953
|
* false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
|
|
2915
2954
|
managed: z.boolean()
|
|
@@ -2921,15 +2960,15 @@ var CreateRequestSchema = z.object({
|
|
|
2921
2960
|
text: z.string().min(1),
|
|
2922
2961
|
/** Land the request on an existing conversation thread (History → "Continue")
|
|
2923
2962
|
* instead of minting a fresh one. Must belong to the requesting user. */
|
|
2924
|
-
|
|
2963
|
+
parentId: z.string().optional(),
|
|
2925
2964
|
/** Point the agent at a past conversation (possibly with a different agent) as
|
|
2926
2965
|
* starting context (History → "New session from this"). A reference, not a copy —
|
|
2927
2966
|
* the agent reads it via get_thread. Must belong to the requesting user. */
|
|
2928
|
-
|
|
2967
|
+
contextParentId: z.string().optional()
|
|
2929
2968
|
});
|
|
2930
2969
|
var HandoffSchema = z.object({
|
|
2931
2970
|
/** Land the note on an existing thread; omitted mints a fresh one. */
|
|
2932
|
-
|
|
2971
|
+
parentId: z.string().uuid().optional(),
|
|
2933
2972
|
/** One-line headline of the working context handed off. */
|
|
2934
2973
|
title: z.string().min(1),
|
|
2935
2974
|
/** The brief — standalone notes the successor reads (what was done, what's left, links). */
|
|
@@ -2968,7 +3007,7 @@ var NoteSchema = z.object({
|
|
|
2968
3007
|
/** Who it was assigned to (a participant ref, 'agent:<tokenId>'); null = unassigned. */
|
|
2969
3008
|
assignee: z.string().nullable(),
|
|
2970
3009
|
/** The request thread minted at assignment; null until assigned. */
|
|
2971
|
-
|
|
3010
|
+
parentId: z.string().nullable(),
|
|
2972
3011
|
createdAt: z.string()
|
|
2973
3012
|
});
|
|
2974
3013
|
var CreateNoteSchema = z.object({
|
|
@@ -2984,8 +3023,17 @@ var RecordDecisionSchema = z.object({
|
|
|
2984
3023
|
answer: z.string().min(1).max(2e3)
|
|
2985
3024
|
}).refine((d) => d.decisionId || d.question, { message: "decisionId or question required" });
|
|
2986
3025
|
var AssignNoteSchema = z.object({
|
|
2987
|
-
|
|
2988
|
-
|
|
3026
|
+
/** An EXISTING agent: token id or nickname. Omit when spawning fresh. */
|
|
3027
|
+
target: z.string().min(1).optional(),
|
|
3028
|
+
/** Spawn a NEW session for this note (companion.md §2.2): the assignee doesn't
|
|
3029
|
+
* exist yet — mint it on a live desktop that advertises the harness+workspace,
|
|
3030
|
+
* named after the note. The brief arrives as its opening request. */
|
|
3031
|
+
spawn: z.object({
|
|
3032
|
+
hostTokenId: z.string().uuid(),
|
|
3033
|
+
harness: z.string().min(1),
|
|
3034
|
+
workspace: z.string().min(1)
|
|
3035
|
+
}).optional()
|
|
3036
|
+
}).refine((a) => !!a.target !== !!a.spawn, { message: "exactly one of target or spawn" });
|
|
2989
3037
|
var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
|
|
2990
3038
|
var RegisterDeliverySchema = z.object({ mode: DeliveryModeSchema });
|
|
2991
3039
|
var OAuthStartSchema = z.object({
|
|
@@ -3045,7 +3093,7 @@ var DeviceRosterSchema = z.object({
|
|
|
3045
3093
|
var WakeNudgeSchema = z.object({
|
|
3046
3094
|
kind: z.enum(["reply", "request", "callback"]),
|
|
3047
3095
|
notificationId: z.string().optional(),
|
|
3048
|
-
|
|
3096
|
+
parentId: z.string()
|
|
3049
3097
|
});
|
|
3050
3098
|
var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
|
|
3051
3099
|
var PairingRevealSchema = z.object({
|
|
@@ -3505,6 +3553,9 @@ function readToken(agent2 = AGENT_NAME) {
|
|
|
3505
3553
|
if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
|
|
3506
3554
|
return readTokenFile()[agent2]?.access_token ?? "";
|
|
3507
3555
|
}
|
|
3556
|
+
function listSlots() {
|
|
3557
|
+
return Object.keys(readTokenFile());
|
|
3558
|
+
}
|
|
3508
3559
|
function deleteToken(agent2 = AGENT_NAME) {
|
|
3509
3560
|
const slots = readTokenFile();
|
|
3510
3561
|
if (!(agent2 in slots)) return false;
|
|
@@ -3710,8 +3761,8 @@ async function sealForE2ee(req, token, deps = {}) {
|
|
|
3710
3761
|
const { context: _c, options: _o, visuals: _v, repo: _r, branch: _b, ...meta } = req;
|
|
3711
3762
|
return { ...meta, envelope };
|
|
3712
3763
|
}
|
|
3713
|
-
async function submitNotification(req) {
|
|
3714
|
-
const token =
|
|
3764
|
+
async function submitNotification(req, opts = {}) {
|
|
3765
|
+
const token = authToken(opts.token) ?? "";
|
|
3715
3766
|
const body = await sealForE2ee(req, token);
|
|
3716
3767
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify`, {
|
|
3717
3768
|
method: "POST",
|
|
@@ -3739,6 +3790,7 @@ async function pollAnswer(notificationId) {
|
|
|
3739
3790
|
const item = await res.json();
|
|
3740
3791
|
return item.type === "idle" ? item : decryptItem(item);
|
|
3741
3792
|
}
|
|
3793
|
+
var AWAIT_WINDOW_MS = 45e3;
|
|
3742
3794
|
var _partialSeen = /* @__PURE__ */ new Map();
|
|
3743
3795
|
async function pollPartials(notificationId) {
|
|
3744
3796
|
const token = readToken();
|
|
@@ -3760,11 +3812,12 @@ async function pollPartials(notificationId) {
|
|
|
3760
3812
|
}
|
|
3761
3813
|
async function awaitReply(notificationId, opts = {}) {
|
|
3762
3814
|
const intervalMs = opts.intervalMs ?? 5e3;
|
|
3763
|
-
const windowMs = opts.windowMs ??
|
|
3815
|
+
const windowMs = opts.windowMs ?? AWAIT_WINDOW_MS;
|
|
3764
3816
|
const doSleep = opts.sleep ?? sleep2;
|
|
3765
3817
|
const now = opts.now ?? Date.now;
|
|
3766
3818
|
const start = now();
|
|
3767
3819
|
while (true) {
|
|
3820
|
+
if (opts.signal?.aborted) return { type: "idle" };
|
|
3768
3821
|
const item = await pollAnswer(notificationId);
|
|
3769
3822
|
if (item.type !== "idle") return item;
|
|
3770
3823
|
const partial = await pollPartials(notificationId);
|
|
@@ -3789,22 +3842,22 @@ function landIntents(intents) {
|
|
|
3789
3842
|
return due !== null ? { ...i, dueInSeconds: due } : i;
|
|
3790
3843
|
});
|
|
3791
3844
|
}
|
|
3792
|
-
async function getThread(
|
|
3793
|
-
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/thread/${encodeURIComponent(
|
|
3794
|
-
headers: { authorization: `Bearer ${
|
|
3845
|
+
async function getThread(parentId) {
|
|
3846
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/thread/${encodeURIComponent(parentId)}`, {
|
|
3847
|
+
headers: { authorization: `Bearer ${authToken()}` }
|
|
3795
3848
|
}));
|
|
3796
3849
|
if (!res.ok) throw new Error(`get_thread failed: ${res.status} ${await res.text()}`);
|
|
3797
3850
|
return await res.json();
|
|
3798
3851
|
}
|
|
3799
3852
|
async function searchThreads(q) {
|
|
3800
3853
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/search?q=${encodeURIComponent(q)}`, {
|
|
3801
|
-
headers: { authorization: `Bearer ${
|
|
3854
|
+
headers: { authorization: `Bearer ${authToken()}` }
|
|
3802
3855
|
}));
|
|
3803
3856
|
if (!res.ok) throw new Error(`search_threads failed: ${res.status} ${await res.text()}`);
|
|
3804
3857
|
return await res.json();
|
|
3805
3858
|
}
|
|
3806
|
-
async function checkReplies() {
|
|
3807
|
-
const token =
|
|
3859
|
+
async function checkReplies(opts = {}) {
|
|
3860
|
+
const token = authToken(opts.token);
|
|
3808
3861
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/pending`, {
|
|
3809
3862
|
headers: { authorization: `Bearer ${token}` }
|
|
3810
3863
|
}));
|
|
@@ -3820,10 +3873,33 @@ async function checkReplies() {
|
|
|
3820
3873
|
})
|
|
3821
3874
|
};
|
|
3822
3875
|
}
|
|
3823
|
-
async function
|
|
3876
|
+
async function answerCallerQuestion(notificationId, answer) {
|
|
3877
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/voice/session-answer`, {
|
|
3878
|
+
method: "POST",
|
|
3879
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
|
|
3880
|
+
body: JSON.stringify({ notificationId, answer })
|
|
3881
|
+
}));
|
|
3882
|
+
if (!res.ok) throw new Error(`answer_caller_question failed: ${res.status} ${await res.text()}`);
|
|
3883
|
+
return await res.json();
|
|
3884
|
+
}
|
|
3885
|
+
var tokenOverride = null;
|
|
3886
|
+
function overrideToken(secret) {
|
|
3887
|
+
tokenOverride = secret;
|
|
3888
|
+
}
|
|
3889
|
+
var authToken = (explicit) => explicit ?? tokenOverride ?? readToken();
|
|
3890
|
+
async function hatch(name, voice = null) {
|
|
3891
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/hatch`, {
|
|
3892
|
+
method: "POST",
|
|
3893
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
|
|
3894
|
+
body: JSON.stringify({ name, voice })
|
|
3895
|
+
}));
|
|
3896
|
+
if (!res.ok) throw new Error(`hatch failed: ${res.status} ${await res.text()}`);
|
|
3897
|
+
return await res.json();
|
|
3898
|
+
}
|
|
3899
|
+
async function setTaskState(notificationId, state, opts = {}) {
|
|
3824
3900
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify/${notificationId}/state`, {
|
|
3825
3901
|
method: "PATCH",
|
|
3826
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${
|
|
3902
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
|
|
3827
3903
|
body: JSON.stringify({ state })
|
|
3828
3904
|
}));
|
|
3829
3905
|
if (!res.ok) throw new Error(`set_task_state failed: ${res.status} ${await res.text()}`);
|
|
@@ -3832,7 +3908,7 @@ async function setTaskState(notificationId, state) {
|
|
|
3832
3908
|
async function registerDelivery(mode) {
|
|
3833
3909
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/delivery`, {
|
|
3834
3910
|
method: "POST",
|
|
3835
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${
|
|
3911
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
|
|
3836
3912
|
body: JSON.stringify({ mode })
|
|
3837
3913
|
}));
|
|
3838
3914
|
if (!res.ok) throw new Error(`register_delivery failed: ${res.status} ${await res.text()}`);
|
|
@@ -3871,6 +3947,7 @@ export {
|
|
|
3871
3947
|
saveToken,
|
|
3872
3948
|
sleep,
|
|
3873
3949
|
readToken,
|
|
3950
|
+
listSlots,
|
|
3874
3951
|
deleteToken,
|
|
3875
3952
|
revokeToken,
|
|
3876
3953
|
requestCode,
|
|
@@ -3888,6 +3965,9 @@ export {
|
|
|
3888
3965
|
getThread,
|
|
3889
3966
|
searchThreads,
|
|
3890
3967
|
checkReplies,
|
|
3968
|
+
answerCallerQuestion,
|
|
3969
|
+
overrideToken,
|
|
3970
|
+
hatch,
|
|
3891
3971
|
setTaskState,
|
|
3892
3972
|
registerDelivery,
|
|
3893
3973
|
scheduleCallback,
|
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-FRYGTKLM.js";
|
|
5
5
|
import {
|
|
6
6
|
PAIGY_TOOL_IDS,
|
|
7
7
|
autoConfigureClients,
|
|
8
8
|
claudeInstallHint,
|
|
9
9
|
enablePaigyTools
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-XQVQ4JRT.js";
|
|
11
11
|
import {
|
|
12
12
|
clearSurface,
|
|
13
13
|
writeSurface
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
ScheduleCallbackSchema,
|
|
18
18
|
SetTaskStateSchema,
|
|
19
19
|
UnpairedError,
|
|
20
|
+
answerCallerQuestion,
|
|
20
21
|
awaitReply,
|
|
21
22
|
checkReplies,
|
|
22
23
|
deleteKeyFile,
|
|
@@ -25,7 +26,10 @@ import {
|
|
|
25
26
|
finalizeE2ee,
|
|
26
27
|
getThread,
|
|
27
28
|
handoff,
|
|
29
|
+
hatch,
|
|
28
30
|
lintNotify,
|
|
31
|
+
listSlots,
|
|
32
|
+
overrideToken,
|
|
29
33
|
pairStep,
|
|
30
34
|
readKeyFile,
|
|
31
35
|
readToken,
|
|
@@ -39,7 +43,7 @@ import {
|
|
|
39
43
|
sleep,
|
|
40
44
|
startE2ee,
|
|
41
45
|
submitNotification
|
|
42
|
-
} from "./chunk-
|
|
46
|
+
} from "./chunk-TE57PAKM.js";
|
|
43
47
|
|
|
44
48
|
// src/index.ts
|
|
45
49
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -111,14 +115,14 @@ var CONTACT_SCHEMA = {
|
|
|
111
115
|
enum: ["call", "message"],
|
|
112
116
|
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."
|
|
113
117
|
},
|
|
114
|
-
|
|
118
|
+
parentId: {
|
|
115
119
|
type: "string",
|
|
116
|
-
description: "To continue an earlier conversation, pass the
|
|
120
|
+
description: "To continue an earlier conversation, pass the parentId a previous contact or reply returned. Omit to start a new one."
|
|
117
121
|
}
|
|
118
122
|
},
|
|
119
123
|
required: ["ask"]
|
|
120
124
|
};
|
|
121
|
-
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,
|
|
125
|
+
var CONTACT_DESCRIPTION = "Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in `ask`, say what happens to your work while you wait in `waiting`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId } \u2014 pass notificationId to await_reply for the answer, parentId to a later contact to continue the conversation. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use `needs` for a genuinely multi-part NEW ask.";
|
|
122
126
|
|
|
123
127
|
// src/pairing.ts
|
|
124
128
|
async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
|
|
@@ -220,13 +224,19 @@ function clearPairing(deviceCode) {
|
|
|
220
224
|
|
|
221
225
|
// src/index.ts
|
|
222
226
|
var AwaitReplySchema = z.object({
|
|
223
|
-
notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only.")
|
|
227
|
+
notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only."),
|
|
228
|
+
maxWaitSeconds: z.number().int().min(5).max(300).optional().describe(
|
|
229
|
+
"How long to hold this ONE call before returning { type:'idle' } so you can loop. Default 45 \u2014 safely under the 60s cap most MCP hosts put on a single tool call. Raise it only if you know your host allows longer; a value past the cap means the call is killed and you get nothing."
|
|
230
|
+
)
|
|
224
231
|
});
|
|
232
|
+
var JOIN_CAP_MS = 45e3;
|
|
225
233
|
var PairSchema = z.object({
|
|
226
|
-
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.")
|
|
234
|
+
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."),
|
|
235
|
+
name: z.string().min(1).max(60).optional().describe("Hatch path only: the name you choose for this identity. Pick your own \u2014 something you'd introduce yourself as on a call."),
|
|
236
|
+
voice: z.string().optional().describe("Hatch path only: your voice on calls \u2014 one of rachel, george, jessica, brian, lily.")
|
|
227
237
|
});
|
|
228
238
|
var GetThreadSchema = z.object({
|
|
229
|
-
|
|
239
|
+
parentId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
|
|
230
240
|
});
|
|
231
241
|
var SearchThreadsSchema = z.object({
|
|
232
242
|
q: z.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
|
|
@@ -238,6 +248,10 @@ var SetTaskStateToolSchema = z.object({
|
|
|
238
248
|
notificationId: z.string(),
|
|
239
249
|
state: SetTaskStateSchema.shape.state
|
|
240
250
|
});
|
|
251
|
+
var AnswerCallerQuestionSchema = z.object({
|
|
252
|
+
notificationId: z.string().describe("The notification whose call carried the caller's question \u2014 from the partial turn or the settled reply."),
|
|
253
|
+
answer: z.string().min(1).max(1500).describe("The answer, as one or two short SPOKEN sentences \u2014 it may be read aloud on the live call.")
|
|
254
|
+
});
|
|
241
255
|
function detectGit() {
|
|
242
256
|
const run = (cmd) => {
|
|
243
257
|
try {
|
|
@@ -319,7 +333,7 @@ function renderPairOutcome(outcome, device_code) {
|
|
|
319
333
|
text: JSON.stringify({
|
|
320
334
|
status: "pending",
|
|
321
335
|
device_code,
|
|
322
|
-
message: "Still awaiting approval. Call pair again with this device_code to keep waiting."
|
|
336
|
+
message: "Still awaiting approval \u2014 the user hasn't entered the code yet. Call pair again with this device_code RIGHT NOW to keep waiting; each call returns after ~45s so it can't be killed by a host tool-call timeout. Nothing is lost between calls: the code stays valid and approval keeps being polled in the background, so a re-call returns the instant they approve."
|
|
323
337
|
})
|
|
324
338
|
}]
|
|
325
339
|
};
|
|
@@ -351,7 +365,7 @@ var server = new Server(
|
|
|
351
365
|
{ name: "paigy", version: "0.0.0" },
|
|
352
366
|
{
|
|
353
367
|
capabilities: { tools: {} },
|
|
354
|
-
instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose
|
|
368
|
+
instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose parentId you don't recognize, or one carrying a contextParentId, means the user is resuming or seeding a past conversation \u2014 call get_thread on it FIRST and treat the transcript as prior conversation, not new input. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without contact + await_reply. 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 you send waiting:'hard' (or the user asked you to call), remember the ask may be spoken aloud \u2014 write it short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. NEVER go quietly idle while something might still be pending for you: whenever you end a turn with any Paigy notification unanswered (or any chance the user replied through the app while you worked), schedule your own ~2-minute wake-up (harness ScheduleWakeup or equivalent) and call check_replies when it fires; if still nothing, re-schedule and keep looping until resolved or the user says stop. For legibility, always use this exact wording \u2014 reason: 'Paigy idle check \u2014 waiting on <thing>', wake-up prompt: 'Paigy idle check: call check_replies and engage with anything unacknowledged; if idle, re-schedule (~2min).' \u2014 so the user can recognize every idle check at a glance. This self-polling in your own live session (full context intact) is the PRIMARY mechanism; the plugin's Stop hooks are only the dead-session safety net."
|
|
355
369
|
}
|
|
356
370
|
);
|
|
357
371
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -366,7 +380,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
366
380
|
},
|
|
367
381
|
{
|
|
368
382
|
name: "pair",
|
|
369
|
-
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.",
|
|
383
|
+
description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before contact/await_reply work. FAST PATH: if this machine already holds a device credential (the user ran the Paigy desktop harness or app), calling pair hatches a fresh identity INSTANTLY \u2014 no code, no approval. Pass { name, voice } to choose who you are (pick your own; voices: rachel, george, jessica, brian, lily). Only when no device credential exists does the code ceremony below run. It does NOT open a browser; the user enters the code in the Paigy app (or scans `qr`). Step 1: call with NO args \u2014 returns { user_code, device_code, qr, user_message } AND starts polling for approval in the background. REQUIRED: You MUST immediately print the `user_message` (the bare code) as a text message to the user, AND in that same turn call step 2 (pair with the device_code). This ensures the user sees the code in chat while the tool blocks/polls in the background for approval. Step 2: call with that device_code to collect the result. Because approval is already being polled in the background, this returns the moment the user approves; on { status:'pending' } just call again to keep waiting; on { status:'awaiting_confirmation' } (E2EE) show the bare `user_message` verify code and call again to finish. The leading text block of every result states the code plainly, so it shows even if you emit no prose. On { status:'paired' } ALWAYS follow the `enable_prompt` \u2014 ask the user to allowlist Paigy's tools so notify/await don't prompt each time.",
|
|
370
384
|
inputSchema: json(PairSchema)
|
|
371
385
|
},
|
|
372
386
|
{
|
|
@@ -381,45 +395,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
381
395
|
},
|
|
382
396
|
{
|
|
383
397
|
name: "await_reply",
|
|
384
|
-
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 ~
|
|
398
|
+
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from contact). This is how you wait for your answer in-context. Polls ~45s per call \u2014 deliberately under the 60s cap most hosts put on a single tool call, so it ALWAYS returns you something (raise it with maxWaitSeconds only if you know your host allows longer). Returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (this window ended, no answer yet). While your contact is being handled on a LIVE call, you may receive { type:'partial', inFlight:true, turn } results: what the user said to each turn, as they say it. Use partials to PREPARE \u2014 fetch the data, draft the thing, warm the build \u2014 never to act irreversibly: the user can still revise any of them until the final reply arrives. Partial = intelligence, settled = authorization. If a partial's acts carry a question aimed at you and you know the answer, call contact on the SAME parentId right away \u2014 the caller hears your answer on the same call instead of waiting for a callback. Keep calling await_reply until you get the final reply \u2014 THAT one is the decision. On idle, if this is genuinely still blocking you and you have nothing else useful to do meanwhile, just call await_reply again immediately \u2014 keep looping. This is how you actually deliver on the point of calling: the user steps away for a while and comes back to find you'd already continued the moment they answered, not idle waiting to be checked on. Don't give up after one window. Only stop looping to do other work (and check back later), or after an unreasonably long stretch (tens of minutes to hours) worth telling the user about instead. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications, so concurrent contact calls don't cross. A CALL answer can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a blocking question'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's parentId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry `intents` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' (\"call me after lunch\") \u2192 register it NOW with schedule_callback \u2014 when the intent carries `dueInSeconds` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' (\"you pick\") \u2192 make the call yourself and tell them what you chose; 'channel' (\"text me next time\") \u2192 honor it on your next contact (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped \"should I call you back?\" because the follow-up is implied. `transcript` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions (\"yes, IF tests pass\") before acting. If your ask declared `points`, the reply carries `covered` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (contact on the same parentId) or proceed knowingly partial; never treat a partial answer as complete.",
|
|
385
399
|
inputSchema: json(AwaitReplySchema)
|
|
386
400
|
},
|
|
387
401
|
{
|
|
388
402
|
name: "check_replies",
|
|
389
|
-
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,
|
|
403
|
+
description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, parentId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. Also returns `threads` \u2014 the SAME replies + requests grouped by conversation, oldest thread first, each with a `busy` flag and its `items` in arrival order. WORK ONE THREAD AT A TIME: take the oldest thread whose `busy` is false, handle ALL of its items together in a single turn (one set_task_state), then go to the next \u2014 don't interleave threads item-by-item. A `busy` thread already has a turn in progress; leave it and let its new items ride the next turn. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a contact call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with contact on its parentId. 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.",
|
|
390
404
|
inputSchema: json(z.object({}))
|
|
391
405
|
},
|
|
392
406
|
{
|
|
393
407
|
name: "get_thread",
|
|
394
|
-
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
|
|
408
|
+
description: "The chronological transcript of one Paigy conversation thread \u2014 every past ask, answer, and user request on it. Call this to REHYDRATE when you're resuming or being seeded: a check_replies request whose parentId you don't recognize means the user is continuing an old conversation with you, and one carrying a contextParentId means they want a past conversation (possibly with a DIFFERENT agent) as your starting context \u2014 in both cases call get_thread FIRST and read the turns as prior conversation you were part of, not as new input. Turns: { role:'agent', title, description[], answer }, { role:'user', text }, and context turns { role:'handoff'|'recap', title, description[] } \u2014 a handoff is a predecessor's brief for you; a recap SUMMARIZES everything before it (the transcript starts at the latest recap, so treat it as the base and the turns after it as what happened since). Oldest first, capped at the most recent 30.",
|
|
395
409
|
inputSchema: json(GetThreadSchema)
|
|
396
410
|
},
|
|
397
411
|
{
|
|
398
412
|
name: "search_threads",
|
|
399
|
-
description: `Search your PAST conversations before asking \u2014 "have we discussed this before?". Full-text over your own threads (the asks you sent + the user's answers); returns ranked threads with highlighted snippets, NOT rows: { hits: [{
|
|
413
|
+
description: `Search your PAST conversations before asking \u2014 "have we discussed this before?". Full-text over your own threads (the asks you sent + the user's answers); returns ranked threads with highlighted snippets, NOT rows: { hits: [{ parentId, at, agentLabel, matches: [{ notificationId, role, snippet }] }] }. The loop this exists for: search first \u2192 get_thread the best hit to rehydrate it \u2192 THEN continue or contact, so you answer with receipts ("last week you said ship it") instead of re-asking. Read-only, safe to call anytime; scoped to your own account's threads.`,
|
|
400
414
|
inputSchema: json(SearchThreadsSchema)
|
|
401
415
|
},
|
|
402
416
|
{
|
|
403
417
|
name: "set_task_state",
|
|
404
|
-
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
|
|
418
|
+
description: "Report progress on the follow-up work behind ANY notification you own \u2014 a user-initiated request (from check_replies), or your OWN contact question once await_reply/check_replies returns its answer and you start acting on it. Pass that notificationId. THIS is what actually claims/acknowledges a reply or request \u2014 check_replies is a pure read that never consumes anything on its own, so call this as soon as you start engaging with something it returned; otherwise that same item just keeps reappearing forever. States: in_progress (you started working), completed (done), or needs_input (you need more from the user \u2014 usually paired with a contact carrying clarifies = the same notificationId you're reporting on). Calling this reliably is also what lets a future session's check_replies surface `stalled` work you (or a crashed/idle prior session) left at in_progress without ever reporting completed.",
|
|
405
419
|
inputSchema: json(SetTaskStateToolSchema)
|
|
406
420
|
},
|
|
421
|
+
{
|
|
422
|
+
name: "answer_caller_question",
|
|
423
|
+
description: "Answer a question the user asked DURING a live call, while they're still on it. When a partial turn or a settled reply carries a `question` intent aimed at you, answer it here immediately: if their call is still live, your answer is spoken to them on that same call (returns live: true). If the call already ended (live: false), send the answer as a threaded contact instead \u2014 never drop it. Short spoken sentences only; this may be read aloud.",
|
|
424
|
+
inputSchema: json(AnswerCallerQuestionSchema)
|
|
425
|
+
},
|
|
407
426
|
{
|
|
408
427
|
name: "schedule_callback",
|
|
409
|
-
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
|
|
428
|
+
description: "Promise the user a follow-up you'll keep even if you go idle. Use it when they ask you to report back: trigger 'on_done' (when you finish \u2014 fires when you call set_task_state completed), 'on_blocked' (if you hit a blocker \u2014 fires on set_task_state needs_input), or 'scheduled' with dueInSeconds (e.g. 'remind me in 10 min'). Pass the parentId of the conversation and a short note. Fulfill it by calling contact on that parentId; check_replies re-lists due callbacks until you do.",
|
|
410
429
|
inputSchema: json(ScheduleCallbackSchema)
|
|
411
430
|
},
|
|
412
431
|
{
|
|
413
432
|
name: "handoff",
|
|
414
|
-
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 `
|
|
433
|
+
description: "Deposit your working context for a SUCCESSOR agent \u2014 what you did, what's left, links, gotchas \u2014 as one note on a thread ({ title, notes[] }). This does NOT ring the user or enter their inbox: it's context, not a question. The successor reads it back with get_thread. Pass `target` (a sibling connection's token id or agent name, SAME account only) to hand off DIRECTLY to that agent \u2014 the note is dispatched to it as a request it picks up. Omit `target` to leave the thread for the user to hand off to an agent themselves in the app. Pass `parentId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { parentId }. Pass recap:true when the note SUMMARIZES the thread so far (for a successor OR for your own later session): a recap resets the rehydration window \u2014 get_thread returns the latest recap + only the turns after it. Write one whenever a thread has grown long and you're pausing, handing off, or nearing your context limit.",
|
|
415
434
|
inputSchema: json(HandoffSchema)
|
|
416
435
|
}
|
|
417
436
|
];
|
|
418
437
|
return { tools };
|
|
419
438
|
});
|
|
420
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
439
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
421
440
|
try {
|
|
422
|
-
return await handleTool(request);
|
|
441
|
+
return await handleTool(request, extra?.signal);
|
|
423
442
|
} catch (e) {
|
|
424
443
|
if (e instanceof UnpairedError) return pairStartResult(await startPairing(suggestedAgentName()));
|
|
425
444
|
throw e;
|
|
@@ -444,14 +463,31 @@ function suggestedAgentName() {
|
|
|
444
463
|
if (!raw) return void 0;
|
|
445
464
|
return CLIENT_LABELS[raw.toLowerCase()] ?? raw.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
446
465
|
}
|
|
447
|
-
async function handleTool(request) {
|
|
466
|
+
async function handleTool(request, signal) {
|
|
448
467
|
switch (request.params.name) {
|
|
449
468
|
case "pair": {
|
|
450
|
-
const { device_code } = PairSchema.parse(request.params.arguments ?? {});
|
|
469
|
+
const { device_code, name, voice } = PairSchema.parse(request.params.arguments ?? {});
|
|
470
|
+
if (!device_code && listSlots().includes("Desktop")) {
|
|
471
|
+
const device = readToken("Desktop");
|
|
472
|
+
overrideToken(device);
|
|
473
|
+
try {
|
|
474
|
+
const minted = await hatch(name ?? suggestedAgentName() ?? "Agent", voice ?? null);
|
|
475
|
+
const dt = { ok: true, access_token: minted.token, name: minted.name, device: null };
|
|
476
|
+
saveToken(dt);
|
|
477
|
+
return pairedResult(
|
|
478
|
+
dt,
|
|
479
|
+
void 0,
|
|
480
|
+
"Hatched instantly under this device's credential \u2014 no code needed. " + (name ? "" : "You were given a default name \u2014 choose your own name and voice and update them via the identity tools or by re-calling pair with { name, voice }.")
|
|
481
|
+
);
|
|
482
|
+
} catch {
|
|
483
|
+
} finally {
|
|
484
|
+
overrideToken(null);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
451
487
|
if (!device_code) {
|
|
452
488
|
return pairStartResult(await startPairing(suggestedAgentName()));
|
|
453
489
|
}
|
|
454
|
-
const capMs =
|
|
490
|
+
const capMs = JOIN_CAP_MS;
|
|
455
491
|
const outcome = await joinBackgroundPair(device_code, capMs) ?? await resolvePairing(device_code, capMs);
|
|
456
492
|
return renderPairOutcome(outcome, device_code);
|
|
457
493
|
}
|
|
@@ -510,8 +546,11 @@ async function handleTool(request) {
|
|
|
510
546
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
511
547
|
}
|
|
512
548
|
case "await_reply": {
|
|
513
|
-
const { notificationId } = AwaitReplySchema.parse(request.params.arguments);
|
|
514
|
-
const item = await awaitReply(notificationId
|
|
549
|
+
const { notificationId, maxWaitSeconds } = AwaitReplySchema.parse(request.params.arguments);
|
|
550
|
+
const item = await awaitReply(notificationId, {
|
|
551
|
+
...maxWaitSeconds !== void 0 ? { windowMs: maxWaitSeconds * 1e3 } : {},
|
|
552
|
+
...signal ? { signal } : {}
|
|
553
|
+
});
|
|
515
554
|
return { content: [{ type: "text", text: JSON.stringify(item) }] };
|
|
516
555
|
}
|
|
517
556
|
case "check_replies": {
|
|
@@ -519,13 +558,21 @@ async function handleTool(request) {
|
|
|
519
558
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
520
559
|
}
|
|
521
560
|
case "get_thread": {
|
|
522
|
-
const {
|
|
523
|
-
return { content: [{ type: "text", text: JSON.stringify(await getThread(
|
|
561
|
+
const { parentId } = GetThreadSchema.parse(request.params.arguments);
|
|
562
|
+
return { content: [{ type: "text", text: JSON.stringify(await getThread(parentId)) }] };
|
|
524
563
|
}
|
|
525
564
|
case "search_threads": {
|
|
526
565
|
const { q } = SearchThreadsSchema.parse(request.params.arguments);
|
|
527
566
|
return { content: [{ type: "text", text: JSON.stringify(await searchThreads(q)) }] };
|
|
528
567
|
}
|
|
568
|
+
case "answer_caller_question": {
|
|
569
|
+
const { notificationId, answer } = AnswerCallerQuestionSchema.parse(request.params.arguments);
|
|
570
|
+
const result = await answerCallerQuestion(notificationId, answer);
|
|
571
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
572
|
+
...result,
|
|
573
|
+
...result.live ? {} : { note: "The call already ended \u2014 send this as a threaded contact instead so the answer still reaches them." }
|
|
574
|
+
}) }] };
|
|
575
|
+
}
|
|
529
576
|
case "set_task_state": {
|
|
530
577
|
const { notificationId, state } = SetTaskStateToolSchema.parse(request.params.arguments);
|
|
531
578
|
const result = await setTaskState(notificationId, state);
|
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-FRYGTKLM.js";
|
|
6
6
|
import {
|
|
7
7
|
checkReplies,
|
|
8
8
|
registerDelivery
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-TE57PAKM.js";
|
|
10
10
|
|
|
11
11
|
// src/listen.ts
|
|
12
12
|
import { createClient } from "@supabase/supabase-js";
|
|
@@ -113,6 +113,58 @@ function uninstallService() {
|
|
|
113
113
|
|
|
114
114
|
// src/listen.ts
|
|
115
115
|
var emit = (obj) => void process.stdout.write(JSON.stringify(obj) + "\n");
|
|
116
|
+
function answerText(answer) {
|
|
117
|
+
switch (answer.kind) {
|
|
118
|
+
case "text":
|
|
119
|
+
return answer.text;
|
|
120
|
+
case "option":
|
|
121
|
+
return answer.label ?? answer.optionId;
|
|
122
|
+
case "multi":
|
|
123
|
+
case "ranked":
|
|
124
|
+
return (answer.labels ?? answer.optionIds).join(", ");
|
|
125
|
+
case "confirm":
|
|
126
|
+
return answer.approved ? "approved" : "declined";
|
|
127
|
+
case "clarify":
|
|
128
|
+
return answer.chunks.join(" ");
|
|
129
|
+
// A call log — PAIGY_TEXT is what the USER said, so the bot's prompts stay out
|
|
130
|
+
// (the full exchange is in PAIGY_WORK for a launcher that wants it).
|
|
131
|
+
case "turns":
|
|
132
|
+
return answer.turns.map((t) => t.reply).join(" ");
|
|
133
|
+
case "ignored":
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function launchEnv(work, reason) {
|
|
138
|
+
const env = { PAIGY_WORK: JSON.stringify(work), PAIGY_EVENT: reason };
|
|
139
|
+
const put = (key, value) => {
|
|
140
|
+
if (value) env[key] = value;
|
|
141
|
+
};
|
|
142
|
+
const lead = work.threads?.find((t) => !t.busy)?.items[0]?.notificationId;
|
|
143
|
+
const reply = lead ? work.replies.find((r) => r.notificationId === lead) : work.replies[0];
|
|
144
|
+
const request = lead ? work.requests.find((r) => r.notificationId === lead) : work.requests[0];
|
|
145
|
+
if (reply) {
|
|
146
|
+
put("PAIGY_THREAD_ID", reply.parentId);
|
|
147
|
+
put("PAIGY_PARENT_ID", reply.parentId);
|
|
148
|
+
put("PAIGY_NOTIFICATION_ID", reply.notificationId);
|
|
149
|
+
put("PAIGY_TEXT", answerText(reply.answer));
|
|
150
|
+
return env;
|
|
151
|
+
}
|
|
152
|
+
if (request) {
|
|
153
|
+
put("PAIGY_THREAD_ID", request.parentId);
|
|
154
|
+
put("PAIGY_PARENT_ID", request.parentId);
|
|
155
|
+
put("PAIGY_NOTIFICATION_ID", request.notificationId);
|
|
156
|
+
put("PAIGY_CONTEXT_THREAD_ID", request.contextParentId);
|
|
157
|
+
put("PAIGY_TEXT", request.text);
|
|
158
|
+
return env;
|
|
159
|
+
}
|
|
160
|
+
const callback = work.owedCallbacks[0];
|
|
161
|
+
if (callback) {
|
|
162
|
+
put("PAIGY_THREAD_ID", callback.parentId);
|
|
163
|
+
put("PAIGY_PARENT_ID", callback.parentId);
|
|
164
|
+
put("PAIGY_TEXT", callback.note);
|
|
165
|
+
}
|
|
166
|
+
return env;
|
|
167
|
+
}
|
|
116
168
|
async function sweep(reason) {
|
|
117
169
|
try {
|
|
118
170
|
const work = await checkReplies();
|
|
@@ -121,7 +173,7 @@ async function sweep(reason) {
|
|
|
121
173
|
const inbound = work.replies.length > 0 || work.requests.length > 0;
|
|
122
174
|
const reengage = work.owedCallbacks.length > 0 && (reason === "boot" || reason.includes("callback"));
|
|
123
175
|
if (cmd && (inbound || reengage)) {
|
|
124
|
-
spawn(cmd, { shell: true, stdio: "inherit", env: { ...process.env,
|
|
176
|
+
spawn(cmd, { shell: true, stdio: "inherit", env: { ...process.env, ...launchEnv(work, reason) } });
|
|
125
177
|
}
|
|
126
178
|
} catch (e) {
|
|
127
179
|
emit({ type: "error", reason, message: e.message });
|
|
@@ -178,5 +230,6 @@ if (isEntry()) {
|
|
|
178
230
|
}
|
|
179
231
|
}
|
|
180
232
|
export {
|
|
233
|
+
launchEnv,
|
|
181
234
|
sweep
|
|
182
235
|
};
|
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-XQVQ4JRT.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-TE57PAKM.js";
|
|
15
15
|
|
|
16
16
|
// src/onboard.ts
|
|
17
17
|
async function main() {
|
package/dist/statusline.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paigy/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Paigy MCP server — 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",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"tsup": "^8.3.5",
|
|
37
37
|
"typescript": "^5.7.2",
|
|
38
38
|
"vitest": "^2.1.8",
|
|
39
|
-
"@paigy/
|
|
39
|
+
"@paigy/crypto": "0.0.0",
|
|
40
40
|
"@paigy/schema": "0.0.0",
|
|
41
|
-
"@paigy/
|
|
41
|
+
"@paigy/sdk": "0.1.0"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsup",
|