@paigy/mcp 0.31.1 → 0.33.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 +2 -2
- package/dist/{chunk-2WPQJYJH.js → chunk-E3OWSUGJ.js} +144 -10
- package/dist/{chunk-PYORUJ7Q.js → chunk-JT7I3EGY.js} +150 -9
- package/dist/{chunk-ENE65M5Y.js → chunk-SASREQWV.js} +3 -1
- package/dist/enable.js +55 -0
- package/dist/index.js +56 -46
- package/dist/listen.js +2 -2
- package/dist/onboard.js +2 -2
- package/dist/statusline.js +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
> QR scan; no pairing codes. Everything below is the manual per-client reference for
|
|
5
5
|
> machines that can't run the harness.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The AI agent harness that calls you. 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.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -118,7 +118,7 @@ Then pair: `PAIGY_AGENT=gemini npx -p @paigy/mcp@latest paigy-mcp-onboard`.
|
|
|
118
118
|
|
|
119
119
|
- **`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.
|
|
120
120
|
- **`unpair`** — log this agent out of the user's Paigy account; revokes the token server-side and deletes the local one.
|
|
121
|
-
- **`
|
|
121
|
+
- **`paigy-enable-tools`** (a CLI, *not* a tool) — allowlist Paigy's notify/await tools so they run without an approval prompt each time. `npx -y -p @paigy/mcp@latest paigy-enable-tools` (or `paigy-harness enable-tools`); `--scope project` limits it to the current repo instead of `~/.claude/settings.json`. Merges, never clobbers; leaves `pair`/`unpair` human-approved. **This is deliberately not an MCP tool.** It writes the calling agent's own permission allowlist, which every host worth trusting treats as privilege escalation — Claude Code's auto mode denies it outright, and the user's consent inside Paigy is invisible to the classifier making that call. `pair` returns the command for the agent to print; a human runs it.
|
|
122
122
|
- **`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.)
|
|
123
123
|
- **`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`.
|
|
124
124
|
- **`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.
|
|
@@ -2390,6 +2390,12 @@ var ReceiptEventSchema = z.enum([
|
|
|
2390
2390
|
// the recipient opened it
|
|
2391
2391
|
"answered",
|
|
2392
2392
|
// the recipient replied
|
|
2393
|
+
// The recipient TURNED THE RING DOWN — CallKit ended it and no answer was ever tapped.
|
|
2394
|
+
// Written by the phone, on the same door that reports the ring itself, so it exists only
|
|
2395
|
+
// when a ring reached a running app and a person did not take it. That is what separates
|
|
2396
|
+
// it from "a ring with no answer", which our own crashes wrote just as readily and which
|
|
2397
|
+
// is why the responsiveness back-off had to be removed (#1144).
|
|
2398
|
+
"declined",
|
|
2393
2399
|
"escalated",
|
|
2394
2400
|
// re-reached at a higher level (re-ring / promote)
|
|
2395
2401
|
"coalesced",
|
|
@@ -2398,8 +2404,14 @@ var ReceiptEventSchema = z.enum([
|
|
|
2398
2404
|
// deadline passed unanswered
|
|
2399
2405
|
"woke",
|
|
2400
2406
|
// the agent was woken for an owed obligation (callback)
|
|
2401
|
-
"gave_up"
|
|
2407
|
+
"gave_up",
|
|
2402
2408
|
// the budget was spent — stopped re-engaging
|
|
2409
|
+
// The ladder starts over — a silent pickup (the owner's fresh-miss rule, 2026-07-28), a
|
|
2410
|
+
// promote to call, a re-delivery. APPENDED, never a rewind: `ring_step` was a cache of
|
|
2411
|
+
// the escalated-count and a writer rewound it to 0 on every unanswered call (2026-08-24,
|
|
2412
|
+
// nine rings in an hour, `gaveUp` unreachable). A count since the last `restarted` cannot
|
|
2413
|
+
// be rewound by a writer that forgot to advance it.
|
|
2414
|
+
"restarted"
|
|
2403
2415
|
]);
|
|
2404
2416
|
var AttentionSchema = z.object({
|
|
2405
2417
|
urgency: NotifyLevelSchema,
|
|
@@ -2531,7 +2543,7 @@ var NotifyRequestSchema = z.object({
|
|
|
2531
2543
|
if (r.ask !== void 0) {
|
|
2532
2544
|
for (const f of ["context", "select", "points"]) {
|
|
2533
2545
|
if (r[f] !== void 0)
|
|
2534
|
-
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it
|
|
2546
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives the answer shape from your prose. Drop ${f} and say it in \`ask\` instead ("should I\u2026" for approve/deny, "which of these\u2026" for a pick), passing \`options\` when you're offering concrete alternatives.` });
|
|
2535
2547
|
}
|
|
2536
2548
|
return;
|
|
2537
2549
|
}
|
|
@@ -2644,6 +2656,14 @@ var IntentSchema = z.object({
|
|
|
2644
2656
|
* about what cannot answer "is the bot looping less this week?". */
|
|
2645
2657
|
fault: z.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
|
|
2646
2658
|
});
|
|
2659
|
+
var RideAlongSchema = z.object({
|
|
2660
|
+
/** The note this came from — assign/clarify/close it through /api/notes/:id. */
|
|
2661
|
+
noteId: z.string(),
|
|
2662
|
+
/** What to do, in the owner's own words (the note's headline). Never model-rewritten. */
|
|
2663
|
+
text: z.string(),
|
|
2664
|
+
/** The thread to report back on, when the note was dispatched over the request rail. */
|
|
2665
|
+
parentId: z.string().nullable()
|
|
2666
|
+
});
|
|
2647
2667
|
var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
2648
2668
|
z.object({
|
|
2649
2669
|
type: z.literal("reply"),
|
|
@@ -2664,7 +2684,13 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
2664
2684
|
transcript: z.string().optional(),
|
|
2665
2685
|
/** Coverage report (#396), when the ask declared `points`: which of them this
|
|
2666
2686
|
* answer addressed. Missing points = re-ask or proceed knowingly partial. */
|
|
2667
|
-
covered: z.array(z.string()).optional()
|
|
2687
|
+
covered: z.array(z.string()).optional(),
|
|
2688
|
+
/** Ride-alongs (RideAlongSchema) — pending work for you, attached to the moment you
|
|
2689
|
+
* became free. Only `reply` and `idle` carry it: those are the two outcomes that
|
|
2690
|
+
* END a wait. `remind`, `superseded` and `turn` are mid-flight, and handing an
|
|
2691
|
+
* agent a side-quest while it is still holding the line is how the main thing gets
|
|
2692
|
+
* dropped. Absent/empty = nothing owed. */
|
|
2693
|
+
also: z.array(RideAlongSchema).optional()
|
|
2668
2694
|
}),
|
|
2669
2695
|
z.object({
|
|
2670
2696
|
type: z.literal("remind"),
|
|
@@ -2699,7 +2725,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
2699
2725
|
acts: z.array(IntentSchema).nullable().optional()
|
|
2700
2726
|
})
|
|
2701
2727
|
}),
|
|
2702
|
-
z.object({ type: z.literal("idle") })
|
|
2728
|
+
z.object({ type: z.literal("idle"), also: z.array(RideAlongSchema).optional() })
|
|
2703
2729
|
]);
|
|
2704
2730
|
var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
|
|
2705
2731
|
var ScheduleCallbackSchema = z.object({
|
|
@@ -2747,6 +2773,9 @@ var PendingRepliesSchema = z.object({
|
|
|
2747
2773
|
/** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
|
|
2748
2774
|
* if blocked, or at a time that has passed). Re-surfaced every sweep until you
|
|
2749
2775
|
* fulfill one by calling contact on its parentId. */
|
|
2776
|
+
/** Ride-alongs (RideAlongSchema): notes assigned to this agent that no wake could
|
|
2777
|
+
* reach. Same array the contact/await replies carry — one queue, every carrier. */
|
|
2778
|
+
also: z.array(RideAlongSchema).optional(),
|
|
2750
2779
|
owedCallbacks: z.array(
|
|
2751
2780
|
z.object({ parentId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
|
|
2752
2781
|
),
|
|
@@ -2782,7 +2811,11 @@ var NotifyResponseSchema = z.object({
|
|
|
2782
2811
|
status: NotifyStatusSchema,
|
|
2783
2812
|
createdAt: z.string().datetime(),
|
|
2784
2813
|
answer: UserAnswerSchema.optional(),
|
|
2785
|
-
answeredAt: z.string().datetime().optional()
|
|
2814
|
+
answeredAt: z.string().datetime().optional(),
|
|
2815
|
+
/** Ride-alongs for THIS agent — pending work it should pick up when it's done with
|
|
2816
|
+
* what it came for. Present on any reply, because an unwakeable agent's only
|
|
2817
|
+
* reliable moment is one it initiated. Absent/empty = nothing owed. */
|
|
2818
|
+
also: z.array(RideAlongSchema).optional()
|
|
2786
2819
|
});
|
|
2787
2820
|
var NotifyPlanUnitSchema = z.object({
|
|
2788
2821
|
notificationId: z.string(),
|
|
@@ -2795,7 +2828,22 @@ var NotifyPlanUnitSchema = z.object({
|
|
|
2795
2828
|
settled: z.literal(true).optional(),
|
|
2796
2829
|
/** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
|
|
2797
2830
|
* agent — nothing here changed the ask, and ignoring it costs nothing. */
|
|
2798
|
-
needs: z.array(z.enum(["options", "visuals"])).optional()
|
|
2831
|
+
needs: z.array(z.enum(["options", "visuals"])).optional(),
|
|
2832
|
+
/** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
|
|
2833
|
+
* split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
|
|
2834
|
+
* broker that DECIDES that destroys the only fact separating a statement from a real ask
|
|
2835
|
+
* (#731), so it is offered, never applied: the unit is stored `text` until the agent
|
|
2836
|
+
* confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
|
|
2837
|
+
proposal: z.object({
|
|
2838
|
+
select: SelectShapeSchema,
|
|
2839
|
+
options: z.array(z.object({ label: z.string().min(1) })).optional()
|
|
2840
|
+
}).optional(),
|
|
2841
|
+
/** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
|
|
2842
|
+
* between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
|
|
2843
|
+
* needs to know. Reported so the agent can correct a misread the same way it ratifies a
|
|
2844
|
+
* shape — the read RAISES (a decision always asks) and never silences a question the
|
|
2845
|
+
* agent declared (#731, #923). */
|
|
2846
|
+
wants: z.enum(["decision", "approval", "knowledge"]).optional()
|
|
2799
2847
|
});
|
|
2800
2848
|
var NotifyPlanSchema = z.object({
|
|
2801
2849
|
units: z.array(NotifyPlanUnitSchema),
|
|
@@ -2819,6 +2867,9 @@ var UserResponseSchema = z.object({
|
|
|
2819
2867
|
});
|
|
2820
2868
|
var VoiceKeySchema = z.enum(["rachel", "george", "jessica", "brian", "lily"]);
|
|
2821
2869
|
var AgendaTurnSchema = z.object({
|
|
2870
|
+
/** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
|
|
2871
|
+
* the planner declares duplicates instead of asking them twice. */
|
|
2872
|
+
coveredIds: z.array(z.string()).optional(),
|
|
2822
2873
|
/** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
|
|
2823
2874
|
* line went out on 2026-07-28 and the caller could not answer it at all. */
|
|
2824
2875
|
info: z.array(z.string().min(1)).max(3).default([]),
|
|
@@ -2852,6 +2903,7 @@ var AgendaTurnSchema = z.object({
|
|
|
2852
2903
|
* on, the claim stays pending; blocking:true on context = hold for a reply. */
|
|
2853
2904
|
blocking: z.boolean().optional()
|
|
2854
2905
|
});
|
|
2906
|
+
var CLAIM_STALE_MS = 30 * 6e4;
|
|
2855
2907
|
var InboxItemSchema = z.object({
|
|
2856
2908
|
id: z.string(),
|
|
2857
2909
|
/** The conversation thread + connection this item lives on. Present on the replied
|
|
@@ -2871,6 +2923,15 @@ var InboxItemSchema = z.object({
|
|
|
2871
2923
|
* (live 2026-08-10, D35). The API already orders by it; this lets a reader that
|
|
2872
2924
|
* re-sorts (grouping, filtering) put an arrival back in the order it was written. */
|
|
2873
2925
|
seq: z.number().int().optional(),
|
|
2926
|
+
/** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
|
|
2927
|
+
* `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
|
|
2928
|
+
* counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
|
|
2929
|
+
* screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
|
|
2930
|
+
* only evidence of itself. How big an arrival is, is a fact about the arrival, so the
|
|
2931
|
+
* server that can still see every row states it. Absent on any row with no `askId`: a
|
|
2932
|
+
* unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
|
|
2933
|
+
* the one part of its own arrival a single row cannot answer. */
|
|
2934
|
+
units: z.number().int().positive().optional(),
|
|
2874
2935
|
tokenId: z.string().optional(),
|
|
2875
2936
|
status: NotifyStatusSchema,
|
|
2876
2937
|
context: ContextSchema,
|
|
@@ -2886,6 +2947,21 @@ var InboxItemSchema = z.object({
|
|
|
2886
2947
|
* its acknowledge affordance: without it a status update offers a text box and a dismiss,
|
|
2887
2948
|
* and neither of those is "got it" (owner, 2026-08-10). */
|
|
2888
2949
|
asks: z.boolean().optional(),
|
|
2950
|
+
/** When a live process last pulsed for this row's agent — the liveness input for
|
|
2951
|
+
* "working requires a pulse" (#928): the list said "Working…" from agent_state alone
|
|
2952
|
+
* while the party called the same dead claim stalled. Absent = no token/no data,
|
|
2953
|
+
* which must never CLAIM stalled. */
|
|
2954
|
+
lastSeenAt: z.string().optional(),
|
|
2955
|
+
/** WHEN THE AGENT LAST SAID ANYTHING ABOUT THIS CLAIM — the newest `agent_state` row in
|
|
2956
|
+
* the `notification_events` ledger (trigger-written since 20260621010000, so every row a
|
|
2957
|
+
* user can see has one). The age input for `CLAIM_STALE_MS`, and it has to be this rather
|
|
2958
|
+
* than `createdAt`: a claim is very often picked up long after the row was born — the
|
|
2959
|
+
* inbox keeps an ANSWERED row visible while the agent works the follow-up, so a question
|
|
2960
|
+
* asked this morning and claimed a minute ago is eight hours old and one minute into its
|
|
2961
|
+
* work. Reading the row's birth as the claim's age brands that "No update in 8h" the
|
|
2962
|
+
* instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
|
|
2963
|
+
* `createdAt`. */
|
|
2964
|
+
agentStateAt: z.string().datetime().optional(),
|
|
2889
2965
|
agenda: z.array(AgendaTurnSchema).optional(),
|
|
2890
2966
|
/** On a replied detail (#397): the next steps the user attached to the answer
|
|
2891
2967
|
* ("call back after lunch") — shown so they can see the commitment was captured. */
|
|
@@ -2924,7 +3000,7 @@ var InboxItemSchema = z.object({
|
|
|
2924
3000
|
why: z.object({
|
|
2925
3001
|
asked: NotifyLevelSchema,
|
|
2926
3002
|
got: NotifyLevelSchema,
|
|
2927
|
-
because: z.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned"]).optional(),
|
|
3003
|
+
because: z.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
|
|
2928
3004
|
line: z.string()
|
|
2929
3005
|
}).optional(),
|
|
2930
3006
|
select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
|
|
@@ -2957,11 +3033,23 @@ var SnoozeRequestSchema = z.object({
|
|
|
2957
3033
|
requestId: z.string(),
|
|
2958
3034
|
until: z.string().datetime()
|
|
2959
3035
|
});
|
|
3036
|
+
var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
|
|
2960
3037
|
var PushTokenSchema = z.object({
|
|
2961
3038
|
voipToken: z.string().min(1).optional(),
|
|
2962
3039
|
alertToken: z.string().min(1).optional(),
|
|
2963
3040
|
fcmToken: z.string().min(1).optional(),
|
|
2964
3041
|
platform: z.enum(["ios", "android"])
|
|
3042
|
+
}).superRefine((v, ctx) => {
|
|
3043
|
+
if (v.platform !== "ios") return;
|
|
3044
|
+
for (const field of ["voipToken", "alertToken"]) {
|
|
3045
|
+
const token = v[field];
|
|
3046
|
+
if (token === void 0 || APNS_TOKEN_RE.test(token)) continue;
|
|
3047
|
+
ctx.addIssue({
|
|
3048
|
+
code: z.ZodIssueCode.custom,
|
|
3049
|
+
path: [field],
|
|
3050
|
+
message: `not an APNs device token (want 64 hex chars, got ${token.length})`
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
2965
3053
|
});
|
|
2966
3054
|
var MissedCallSchema = z.enum([
|
|
2967
3055
|
"retry_10m",
|
|
@@ -3012,6 +3100,15 @@ var UserSettingsSchema = z.object({
|
|
|
3012
3100
|
/** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
|
|
3013
3101
|
* defaulted — an older client PATCHing the full object must not clobber it. */
|
|
3014
3102
|
pstnCalls: z.boolean().optional(),
|
|
3103
|
+
/** The user's IANA timezone (e.g. "America/Bogota"), recorded by the app — it is the
|
|
3104
|
+
* only party that knows it. REMINDERS are why it exists: "remind me at ten" becomes
|
|
3105
|
+
* an absolute `due_at` only if we know whose ten. Optional and never defaulted, for
|
|
3106
|
+
* the same reason `voiceMode` is (a stale client PATCHing the whole object must not
|
|
3107
|
+
* clobber it) and one more: a GUESSED timezone schedules reminders hours off, and
|
|
3108
|
+
* that failure reads as the reminder rail being unreliable rather than as a missing
|
|
3109
|
+
* setting. Absent = a spoken time can't be landed, so the reminder rides the next
|
|
3110
|
+
* call — honest about what we know. */
|
|
3111
|
+
timezone: z.string().min(1).max(64).optional(),
|
|
3015
3112
|
/** Account E2EE state (text lane): 'off' (default) = today's plaintext; 'on' =
|
|
3016
3113
|
* content is sealed end-to-end between the local agent and the phone. Like
|
|
3017
3114
|
* voiceMode, OPTIONAL and NOT defaulted so a stale client PATCHing the full
|
|
@@ -3037,6 +3134,15 @@ var HistoryItemSchema = z.object({
|
|
|
3037
3134
|
/** When you answered the agent's notification (agent→user only). */
|
|
3038
3135
|
humanAckedAt: z.string().nullable()
|
|
3039
3136
|
});
|
|
3137
|
+
var ACTIVITY_LINES = 2;
|
|
3138
|
+
var ACTIVITY_LINE_MAX = 80;
|
|
3139
|
+
var AgentActivitySchema = z.object({
|
|
3140
|
+
/** Oldest first, so the newest line is last — the one that replaces in place. */
|
|
3141
|
+
lines: z.array(z.string().max(ACTIVITY_LINE_MAX)).max(ACTIVITY_LINES),
|
|
3142
|
+
/** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
|
|
3143
|
+
* that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
|
|
3144
|
+
at: z.string().datetime()
|
|
3145
|
+
});
|
|
3040
3146
|
var ConnectionSummarySchema = z.object({
|
|
3041
3147
|
/** The connection = the agent's token id (used to address a request). */
|
|
3042
3148
|
id: z.string(),
|
|
@@ -3070,6 +3176,11 @@ var ConnectionSummarySchema = z.object({
|
|
|
3070
3176
|
harnesses: z.array(z.object({ name: z.string(), label: z.string(), status: z.string() })).optional(),
|
|
3071
3177
|
workspaces: z.array(z.string()).optional()
|
|
3072
3178
|
}).optional(),
|
|
3179
|
+
/** The tail of this agent's working log, when a harness is driving it — the agent page's
|
|
3180
|
+
* live strip. Absent for anything the desktop harness isn't running (a hatched identity
|
|
3181
|
+
* used straight from a terminal emits no work events; the page says so rather than
|
|
3182
|
+
* drawing an empty box). */
|
|
3183
|
+
activity: AgentActivitySchema.optional(),
|
|
3073
3184
|
/** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
|
|
3074
3185
|
* false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
|
|
3075
3186
|
managed: z.boolean()
|
|
@@ -3138,7 +3249,8 @@ var HandoffSchema = z.object({
|
|
|
3138
3249
|
recap: z.boolean().optional()
|
|
3139
3250
|
});
|
|
3140
3251
|
var NoteSourceSchema = z.enum(["app", "call"]);
|
|
3141
|
-
var NoteStatusSchema = z.enum(["open", "assigned", "done"]);
|
|
3252
|
+
var NoteStatusSchema = z.enum(["open", "assigned", "in_progress", "done"]);
|
|
3253
|
+
var NoteRepeatSchema = z.enum(["once", "until_done"]);
|
|
3142
3254
|
var DecisionSchema = z.object({
|
|
3143
3255
|
id: z.string(),
|
|
3144
3256
|
/** The note this decision refines; null = recorded on a bare thread (the
|
|
@@ -3163,11 +3275,29 @@ var NoteSchema = z.object({
|
|
|
3163
3275
|
assignee: z.string().nullable(),
|
|
3164
3276
|
/** The request thread minted at assignment; null until assigned. */
|
|
3165
3277
|
parentId: z.string().nullable(),
|
|
3278
|
+
/** REMINDERS (reminders-design.md): the NOT-BEFORE this becomes eligible to ride a
|
|
3279
|
+
* call — never a deadline, and nothing rings when it passes. Null = "the very next
|
|
3280
|
+
* call", the right reading of "remind me to…" with no time attached. */
|
|
3281
|
+
// Defaulted, not required: a Note from an API deploy older than the reminders
|
|
3282
|
+
// migration has none of these, and the defaults ARE what it means — no not-before,
|
|
3283
|
+
// one ride, never ridden. Parsing must not fail across a rolling deploy.
|
|
3284
|
+
dueAt: z.string().nullable().default(null),
|
|
3285
|
+
repeat: NoteRepeatSchema.default("once"),
|
|
3286
|
+
/** How many calls have already carried it — the fatigue cap counts rides, not days. */
|
|
3287
|
+
rides: z.number().int().default(0),
|
|
3288
|
+
lastRideAt: z.string().nullable().default(null),
|
|
3166
3289
|
createdAt: z.string()
|
|
3167
3290
|
});
|
|
3168
3291
|
var CreateNoteSchema = z.object({
|
|
3169
3292
|
/** The intent, in the user's own words. Stored verbatim; the broker only titles it. */
|
|
3170
|
-
text: z.string().min(1).max(4e3)
|
|
3293
|
+
text: z.string().min(1).max(4e3),
|
|
3294
|
+
/** Capture it as a REMINDER — a note assigned to the user themselves, which rides
|
|
3295
|
+
* their next call instead of being handed to an agent. Everything else about the
|
|
3296
|
+
* note is identical; this is the one parameter that separates the two. */
|
|
3297
|
+
forMe: z.boolean().optional(),
|
|
3298
|
+
/** The not-before, when the user already said one. Absent = the very next call. */
|
|
3299
|
+
dueAt: z.string().datetime().optional(),
|
|
3300
|
+
repeat: NoteRepeatSchema.optional()
|
|
3171
3301
|
});
|
|
3172
3302
|
var RecordDecisionSchema = z.object({
|
|
3173
3303
|
/** An open decision (from /clarify) to answer. */
|
|
@@ -4114,10 +4244,14 @@ async function setIdentity(patch, opts = {}) {
|
|
|
4114
4244
|
return await res.json();
|
|
4115
4245
|
}
|
|
4116
4246
|
async function heartbeat(runtime, opts = {}) {
|
|
4247
|
+
const body = {
|
|
4248
|
+
...runtime !== void 0 ? { runtime } : {},
|
|
4249
|
+
...opts.activity !== void 0 ? { activity: opts.activity } : {}
|
|
4250
|
+
};
|
|
4117
4251
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/presence`, {
|
|
4118
4252
|
method: "POST",
|
|
4119
4253
|
headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
|
|
4120
|
-
...
|
|
4254
|
+
...Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}
|
|
4121
4255
|
}));
|
|
4122
4256
|
if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
|
|
4123
4257
|
}
|
|
@@ -51,6 +51,12 @@ var ReceiptEventSchema = z.enum([
|
|
|
51
51
|
// the recipient opened it
|
|
52
52
|
"answered",
|
|
53
53
|
// the recipient replied
|
|
54
|
+
// The recipient TURNED THE RING DOWN — CallKit ended it and no answer was ever tapped.
|
|
55
|
+
// Written by the phone, on the same door that reports the ring itself, so it exists only
|
|
56
|
+
// when a ring reached a running app and a person did not take it. That is what separates
|
|
57
|
+
// it from "a ring with no answer", which our own crashes wrote just as readily and which
|
|
58
|
+
// is why the responsiveness back-off had to be removed (#1144).
|
|
59
|
+
"declined",
|
|
54
60
|
"escalated",
|
|
55
61
|
// re-reached at a higher level (re-ring / promote)
|
|
56
62
|
"coalesced",
|
|
@@ -59,8 +65,14 @@ var ReceiptEventSchema = z.enum([
|
|
|
59
65
|
// deadline passed unanswered
|
|
60
66
|
"woke",
|
|
61
67
|
// the agent was woken for an owed obligation (callback)
|
|
62
|
-
"gave_up"
|
|
68
|
+
"gave_up",
|
|
63
69
|
// the budget was spent — stopped re-engaging
|
|
70
|
+
// The ladder starts over — a silent pickup (the owner's fresh-miss rule, 2026-07-28), a
|
|
71
|
+
// promote to call, a re-delivery. APPENDED, never a rewind: `ring_step` was a cache of
|
|
72
|
+
// the escalated-count and a writer rewound it to 0 on every unanswered call (2026-08-24,
|
|
73
|
+
// nine rings in an hour, `gaveUp` unreachable). A count since the last `restarted` cannot
|
|
74
|
+
// be rewound by a writer that forgot to advance it.
|
|
75
|
+
"restarted"
|
|
64
76
|
]);
|
|
65
77
|
var AttentionSchema = z.object({
|
|
66
78
|
urgency: NotifyLevelSchema,
|
|
@@ -192,7 +204,7 @@ var NotifyRequestSchema = z.object({
|
|
|
192
204
|
if (r.ask !== void 0) {
|
|
193
205
|
for (const f of ["context", "select", "points"]) {
|
|
194
206
|
if (r[f] !== void 0)
|
|
195
|
-
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it
|
|
207
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives the answer shape from your prose. Drop ${f} and say it in \`ask\` instead ("should I\u2026" for approve/deny, "which of these\u2026" for a pick), passing \`options\` when you're offering concrete alternatives.` });
|
|
196
208
|
}
|
|
197
209
|
return;
|
|
198
210
|
}
|
|
@@ -250,6 +262,14 @@ var IntentSchema = z.object({
|
|
|
250
262
|
* about what cannot answer "is the bot looping less this week?". */
|
|
251
263
|
fault: z.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
|
|
252
264
|
});
|
|
265
|
+
var RideAlongSchema = z.object({
|
|
266
|
+
/** The note this came from — assign/clarify/close it through /api/notes/:id. */
|
|
267
|
+
noteId: z.string(),
|
|
268
|
+
/** What to do, in the owner's own words (the note's headline). Never model-rewritten. */
|
|
269
|
+
text: z.string(),
|
|
270
|
+
/** The thread to report back on, when the note was dispatched over the request rail. */
|
|
271
|
+
parentId: z.string().nullable()
|
|
272
|
+
});
|
|
253
273
|
var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
254
274
|
z.object({
|
|
255
275
|
type: z.literal("reply"),
|
|
@@ -270,7 +290,13 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
270
290
|
transcript: z.string().optional(),
|
|
271
291
|
/** Coverage report (#396), when the ask declared `points`: which of them this
|
|
272
292
|
* answer addressed. Missing points = re-ask or proceed knowingly partial. */
|
|
273
|
-
covered: z.array(z.string()).optional()
|
|
293
|
+
covered: z.array(z.string()).optional(),
|
|
294
|
+
/** Ride-alongs (RideAlongSchema) — pending work for you, attached to the moment you
|
|
295
|
+
* became free. Only `reply` and `idle` carry it: those are the two outcomes that
|
|
296
|
+
* END a wait. `remind`, `superseded` and `turn` are mid-flight, and handing an
|
|
297
|
+
* agent a side-quest while it is still holding the line is how the main thing gets
|
|
298
|
+
* dropped. Absent/empty = nothing owed. */
|
|
299
|
+
also: z.array(RideAlongSchema).optional()
|
|
274
300
|
}),
|
|
275
301
|
z.object({
|
|
276
302
|
type: z.literal("remind"),
|
|
@@ -305,7 +331,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
305
331
|
acts: z.array(IntentSchema).nullable().optional()
|
|
306
332
|
})
|
|
307
333
|
}),
|
|
308
|
-
z.object({ type: z.literal("idle") })
|
|
334
|
+
z.object({ type: z.literal("idle"), also: z.array(RideAlongSchema).optional() })
|
|
309
335
|
]);
|
|
310
336
|
var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
|
|
311
337
|
var ScheduleCallbackSchema = z.object({
|
|
@@ -353,6 +379,9 @@ var PendingRepliesSchema = z.object({
|
|
|
353
379
|
/** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
|
|
354
380
|
* if blocked, or at a time that has passed). Re-surfaced every sweep until you
|
|
355
381
|
* fulfill one by calling contact on its parentId. */
|
|
382
|
+
/** Ride-alongs (RideAlongSchema): notes assigned to this agent that no wake could
|
|
383
|
+
* reach. Same array the contact/await replies carry — one queue, every carrier. */
|
|
384
|
+
also: z.array(RideAlongSchema).optional(),
|
|
356
385
|
owedCallbacks: z.array(
|
|
357
386
|
z.object({ parentId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
|
|
358
387
|
),
|
|
@@ -388,7 +417,11 @@ var NotifyResponseSchema = z.object({
|
|
|
388
417
|
status: NotifyStatusSchema,
|
|
389
418
|
createdAt: z.string().datetime(),
|
|
390
419
|
answer: UserAnswerSchema.optional(),
|
|
391
|
-
answeredAt: z.string().datetime().optional()
|
|
420
|
+
answeredAt: z.string().datetime().optional(),
|
|
421
|
+
/** Ride-alongs for THIS agent — pending work it should pick up when it's done with
|
|
422
|
+
* what it came for. Present on any reply, because an unwakeable agent's only
|
|
423
|
+
* reliable moment is one it initiated. Absent/empty = nothing owed. */
|
|
424
|
+
also: z.array(RideAlongSchema).optional()
|
|
392
425
|
});
|
|
393
426
|
var NotifyPlanUnitSchema = z.object({
|
|
394
427
|
notificationId: z.string(),
|
|
@@ -401,7 +434,22 @@ var NotifyPlanUnitSchema = z.object({
|
|
|
401
434
|
settled: z.literal(true).optional(),
|
|
402
435
|
/** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
|
|
403
436
|
* agent — nothing here changed the ask, and ignoring it costs nothing. */
|
|
404
|
-
needs: z.array(z.enum(["options", "visuals"])).optional()
|
|
437
|
+
needs: z.array(z.enum(["options", "visuals"])).optional(),
|
|
438
|
+
/** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
|
|
439
|
+
* split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
|
|
440
|
+
* broker that DECIDES that destroys the only fact separating a statement from a real ask
|
|
441
|
+
* (#731), so it is offered, never applied: the unit is stored `text` until the agent
|
|
442
|
+
* confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
|
|
443
|
+
proposal: z.object({
|
|
444
|
+
select: SelectShapeSchema,
|
|
445
|
+
options: z.array(z.object({ label: z.string().min(1) })).optional()
|
|
446
|
+
}).optional(),
|
|
447
|
+
/** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
|
|
448
|
+
* between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
|
|
449
|
+
* needs to know. Reported so the agent can correct a misread the same way it ratifies a
|
|
450
|
+
* shape — the read RAISES (a decision always asks) and never silences a question the
|
|
451
|
+
* agent declared (#731, #923). */
|
|
452
|
+
wants: z.enum(["decision", "approval", "knowledge"]).optional()
|
|
405
453
|
});
|
|
406
454
|
var NotifyPlanSchema = z.object({
|
|
407
455
|
units: z.array(NotifyPlanUnitSchema),
|
|
@@ -425,6 +473,9 @@ var UserResponseSchema = z.object({
|
|
|
425
473
|
});
|
|
426
474
|
var VoiceKeySchema = z.enum(["rachel", "george", "jessica", "brian", "lily"]);
|
|
427
475
|
var AgendaTurnSchema = z.object({
|
|
476
|
+
/** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
|
|
477
|
+
* the planner declares duplicates instead of asking them twice. */
|
|
478
|
+
coveredIds: z.array(z.string()).optional(),
|
|
428
479
|
/** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
|
|
429
480
|
* line went out on 2026-07-28 and the caller could not answer it at all. */
|
|
430
481
|
info: z.array(z.string().min(1)).max(3).default([]),
|
|
@@ -458,6 +509,7 @@ var AgendaTurnSchema = z.object({
|
|
|
458
509
|
* on, the claim stays pending; blocking:true on context = hold for a reply. */
|
|
459
510
|
blocking: z.boolean().optional()
|
|
460
511
|
});
|
|
512
|
+
var CLAIM_STALE_MS = 30 * 6e4;
|
|
461
513
|
var InboxItemSchema = z.object({
|
|
462
514
|
id: z.string(),
|
|
463
515
|
/** The conversation thread + connection this item lives on. Present on the replied
|
|
@@ -477,6 +529,15 @@ var InboxItemSchema = z.object({
|
|
|
477
529
|
* (live 2026-08-10, D35). The API already orders by it; this lets a reader that
|
|
478
530
|
* re-sorts (grouping, filtering) put an arrival back in the order it was written. */
|
|
479
531
|
seq: z.number().int().optional(),
|
|
532
|
+
/** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
|
|
533
|
+
* `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
|
|
534
|
+
* counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
|
|
535
|
+
* screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
|
|
536
|
+
* only evidence of itself. How big an arrival is, is a fact about the arrival, so the
|
|
537
|
+
* server that can still see every row states it. Absent on any row with no `askId`: a
|
|
538
|
+
* unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
|
|
539
|
+
* the one part of its own arrival a single row cannot answer. */
|
|
540
|
+
units: z.number().int().positive().optional(),
|
|
480
541
|
tokenId: z.string().optional(),
|
|
481
542
|
status: NotifyStatusSchema,
|
|
482
543
|
context: ContextSchema,
|
|
@@ -492,6 +553,21 @@ var InboxItemSchema = z.object({
|
|
|
492
553
|
* its acknowledge affordance: without it a status update offers a text box and a dismiss,
|
|
493
554
|
* and neither of those is "got it" (owner, 2026-08-10). */
|
|
494
555
|
asks: z.boolean().optional(),
|
|
556
|
+
/** When a live process last pulsed for this row's agent — the liveness input for
|
|
557
|
+
* "working requires a pulse" (#928): the list said "Working…" from agent_state alone
|
|
558
|
+
* while the party called the same dead claim stalled. Absent = no token/no data,
|
|
559
|
+
* which must never CLAIM stalled. */
|
|
560
|
+
lastSeenAt: z.string().optional(),
|
|
561
|
+
/** WHEN THE AGENT LAST SAID ANYTHING ABOUT THIS CLAIM — the newest `agent_state` row in
|
|
562
|
+
* the `notification_events` ledger (trigger-written since 20260621010000, so every row a
|
|
563
|
+
* user can see has one). The age input for `CLAIM_STALE_MS`, and it has to be this rather
|
|
564
|
+
* than `createdAt`: a claim is very often picked up long after the row was born — the
|
|
565
|
+
* inbox keeps an ANSWERED row visible while the agent works the follow-up, so a question
|
|
566
|
+
* asked this morning and claimed a minute ago is eight hours old and one minute into its
|
|
567
|
+
* work. Reading the row's birth as the claim's age brands that "No update in 8h" the
|
|
568
|
+
* instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
|
|
569
|
+
* `createdAt`. */
|
|
570
|
+
agentStateAt: z.string().datetime().optional(),
|
|
495
571
|
agenda: z.array(AgendaTurnSchema).optional(),
|
|
496
572
|
/** On a replied detail (#397): the next steps the user attached to the answer
|
|
497
573
|
* ("call back after lunch") — shown so they can see the commitment was captured. */
|
|
@@ -530,7 +606,7 @@ var InboxItemSchema = z.object({
|
|
|
530
606
|
why: z.object({
|
|
531
607
|
asked: NotifyLevelSchema,
|
|
532
608
|
got: NotifyLevelSchema,
|
|
533
|
-
because: z.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned"]).optional(),
|
|
609
|
+
because: z.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
|
|
534
610
|
line: z.string()
|
|
535
611
|
}).optional(),
|
|
536
612
|
select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
|
|
@@ -563,11 +639,23 @@ var SnoozeRequestSchema = z.object({
|
|
|
563
639
|
requestId: z.string(),
|
|
564
640
|
until: z.string().datetime()
|
|
565
641
|
});
|
|
642
|
+
var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
|
|
566
643
|
var PushTokenSchema = z.object({
|
|
567
644
|
voipToken: z.string().min(1).optional(),
|
|
568
645
|
alertToken: z.string().min(1).optional(),
|
|
569
646
|
fcmToken: z.string().min(1).optional(),
|
|
570
647
|
platform: z.enum(["ios", "android"])
|
|
648
|
+
}).superRefine((v, ctx) => {
|
|
649
|
+
if (v.platform !== "ios") return;
|
|
650
|
+
for (const field of ["voipToken", "alertToken"]) {
|
|
651
|
+
const token = v[field];
|
|
652
|
+
if (token === void 0 || APNS_TOKEN_RE.test(token)) continue;
|
|
653
|
+
ctx.addIssue({
|
|
654
|
+
code: z.ZodIssueCode.custom,
|
|
655
|
+
path: [field],
|
|
656
|
+
message: `not an APNs device token (want 64 hex chars, got ${token.length})`
|
|
657
|
+
});
|
|
658
|
+
}
|
|
571
659
|
});
|
|
572
660
|
var MissedCallSchema = z.enum([
|
|
573
661
|
"retry_10m",
|
|
@@ -579,6 +667,16 @@ var MissedCallSchema = z.enum([
|
|
|
579
667
|
"inbox",
|
|
580
668
|
"dismiss"
|
|
581
669
|
]);
|
|
670
|
+
var MISSED_CALL_PLAN = {
|
|
671
|
+
retry_10m: { kind: "every", minutes: 10 },
|
|
672
|
+
retry_30m: { kind: "every", minutes: 30 },
|
|
673
|
+
retry_60m: { kind: "every", minutes: 60 },
|
|
674
|
+
backoff_gentle: { kind: "at", minutes: [30, 120, 360] },
|
|
675
|
+
backoff_standard: { kind: "at", minutes: [10, 30, 120] },
|
|
676
|
+
backoff_aggressive: { kind: "at", minutes: [5, 15, 45] },
|
|
677
|
+
inbox: { kind: "once" },
|
|
678
|
+
dismiss: { kind: "grace", minutes: 2 }
|
|
679
|
+
};
|
|
582
680
|
var BrokerTuningSchema = z.object({
|
|
583
681
|
/** 'none' = skip the spoken ack after a mapped answer (power users find it slow). */
|
|
584
682
|
ackVerbosity: z.enum(["normal", "none"]).optional(),
|
|
@@ -618,6 +716,15 @@ var UserSettingsSchema = z.object({
|
|
|
618
716
|
/** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
|
|
619
717
|
* defaulted — an older client PATCHing the full object must not clobber it. */
|
|
620
718
|
pstnCalls: z.boolean().optional(),
|
|
719
|
+
/** The user's IANA timezone (e.g. "America/Bogota"), recorded by the app — it is the
|
|
720
|
+
* only party that knows it. REMINDERS are why it exists: "remind me at ten" becomes
|
|
721
|
+
* an absolute `due_at` only if we know whose ten. Optional and never defaulted, for
|
|
722
|
+
* the same reason `voiceMode` is (a stale client PATCHing the whole object must not
|
|
723
|
+
* clobber it) and one more: a GUESSED timezone schedules reminders hours off, and
|
|
724
|
+
* that failure reads as the reminder rail being unreliable rather than as a missing
|
|
725
|
+
* setting. Absent = a spoken time can't be landed, so the reminder rides the next
|
|
726
|
+
* call — honest about what we know. */
|
|
727
|
+
timezone: z.string().min(1).max(64).optional(),
|
|
621
728
|
/** Account E2EE state (text lane): 'off' (default) = today's plaintext; 'on' =
|
|
622
729
|
* content is sealed end-to-end between the local agent and the phone. Like
|
|
623
730
|
* voiceMode, OPTIONAL and NOT defaulted so a stale client PATCHing the full
|
|
@@ -643,6 +750,15 @@ var HistoryItemSchema = z.object({
|
|
|
643
750
|
/** When you answered the agent's notification (agent→user only). */
|
|
644
751
|
humanAckedAt: z.string().nullable()
|
|
645
752
|
});
|
|
753
|
+
var ACTIVITY_LINES = 2;
|
|
754
|
+
var ACTIVITY_LINE_MAX = 80;
|
|
755
|
+
var AgentActivitySchema = z.object({
|
|
756
|
+
/** Oldest first, so the newest line is last — the one that replaces in place. */
|
|
757
|
+
lines: z.array(z.string().max(ACTIVITY_LINE_MAX)).max(ACTIVITY_LINES),
|
|
758
|
+
/** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
|
|
759
|
+
* that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
|
|
760
|
+
at: z.string().datetime()
|
|
761
|
+
});
|
|
646
762
|
var ConnectionSummarySchema = z.object({
|
|
647
763
|
/** The connection = the agent's token id (used to address a request). */
|
|
648
764
|
id: z.string(),
|
|
@@ -676,6 +792,11 @@ var ConnectionSummarySchema = z.object({
|
|
|
676
792
|
harnesses: z.array(z.object({ name: z.string(), label: z.string(), status: z.string() })).optional(),
|
|
677
793
|
workspaces: z.array(z.string()).optional()
|
|
678
794
|
}).optional(),
|
|
795
|
+
/** The tail of this agent's working log, when a harness is driving it — the agent page's
|
|
796
|
+
* live strip. Absent for anything the desktop harness isn't running (a hatched identity
|
|
797
|
+
* used straight from a terminal emits no work events; the page says so rather than
|
|
798
|
+
* drawing an empty box). */
|
|
799
|
+
activity: AgentActivitySchema.optional(),
|
|
679
800
|
/** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
|
|
680
801
|
* false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
|
|
681
802
|
managed: z.boolean()
|
|
@@ -744,7 +865,8 @@ var HandoffSchema = z.object({
|
|
|
744
865
|
recap: z.boolean().optional()
|
|
745
866
|
});
|
|
746
867
|
var NoteSourceSchema = z.enum(["app", "call"]);
|
|
747
|
-
var NoteStatusSchema = z.enum(["open", "assigned", "done"]);
|
|
868
|
+
var NoteStatusSchema = z.enum(["open", "assigned", "in_progress", "done"]);
|
|
869
|
+
var NoteRepeatSchema = z.enum(["once", "until_done"]);
|
|
748
870
|
var DecisionSchema = z.object({
|
|
749
871
|
id: z.string(),
|
|
750
872
|
/** The note this decision refines; null = recorded on a bare thread (the
|
|
@@ -769,11 +891,29 @@ var NoteSchema = z.object({
|
|
|
769
891
|
assignee: z.string().nullable(),
|
|
770
892
|
/** The request thread minted at assignment; null until assigned. */
|
|
771
893
|
parentId: z.string().nullable(),
|
|
894
|
+
/** REMINDERS (reminders-design.md): the NOT-BEFORE this becomes eligible to ride a
|
|
895
|
+
* call — never a deadline, and nothing rings when it passes. Null = "the very next
|
|
896
|
+
* call", the right reading of "remind me to…" with no time attached. */
|
|
897
|
+
// Defaulted, not required: a Note from an API deploy older than the reminders
|
|
898
|
+
// migration has none of these, and the defaults ARE what it means — no not-before,
|
|
899
|
+
// one ride, never ridden. Parsing must not fail across a rolling deploy.
|
|
900
|
+
dueAt: z.string().nullable().default(null),
|
|
901
|
+
repeat: NoteRepeatSchema.default("once"),
|
|
902
|
+
/** How many calls have already carried it — the fatigue cap counts rides, not days. */
|
|
903
|
+
rides: z.number().int().default(0),
|
|
904
|
+
lastRideAt: z.string().nullable().default(null),
|
|
772
905
|
createdAt: z.string()
|
|
773
906
|
});
|
|
774
907
|
var CreateNoteSchema = z.object({
|
|
775
908
|
/** The intent, in the user's own words. Stored verbatim; the broker only titles it. */
|
|
776
|
-
text: z.string().min(1).max(4e3)
|
|
909
|
+
text: z.string().min(1).max(4e3),
|
|
910
|
+
/** Capture it as a REMINDER — a note assigned to the user themselves, which rides
|
|
911
|
+
* their next call instead of being handed to an agent. Everything else about the
|
|
912
|
+
* note is identical; this is the one parameter that separates the two. */
|
|
913
|
+
forMe: z.boolean().optional(),
|
|
914
|
+
/** The not-before, when the user already said one. Absent = the very next call. */
|
|
915
|
+
dueAt: z.string().datetime().optional(),
|
|
916
|
+
repeat: NoteRepeatSchema.optional()
|
|
777
917
|
});
|
|
778
918
|
var RecordDecisionSchema = z.object({
|
|
779
919
|
/** An open decision (from /clarify) to answer. */
|
|
@@ -982,6 +1122,7 @@ var FeedbackOutcomeSchema = z.object({
|
|
|
982
1122
|
});
|
|
983
1123
|
|
|
984
1124
|
export {
|
|
1125
|
+
MISSED_CALL_PLAN,
|
|
985
1126
|
HandoffSchema,
|
|
986
1127
|
WAKE_EVENT,
|
|
987
1128
|
wakeChannel
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AGENT_NAME
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-E3OWSUGJ.js";
|
|
4
4
|
|
|
5
5
|
// src/clients.ts
|
|
6
6
|
import { execFile } from "child_process";
|
|
@@ -87,6 +87,7 @@ function claudeInstallHint(skip = AGENT_NAME) {
|
|
|
87
87
|
}
|
|
88
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
|
+
var ENABLE_COMMAND = "npx -y -p @paigy/mcp@latest paigy-enable-tools";
|
|
90
91
|
var PAIGY_TOOL_IDS = [
|
|
91
92
|
"mcp__paigy__contact",
|
|
92
93
|
"mcp__paigy__notify_user",
|
|
@@ -169,6 +170,7 @@ export {
|
|
|
169
170
|
openBrowser,
|
|
170
171
|
autoConfigureClients,
|
|
171
172
|
claudeInstallHint,
|
|
173
|
+
ENABLE_COMMAND,
|
|
172
174
|
PAIGY_TOOL_IDS,
|
|
173
175
|
paigyToolsAllowlisted,
|
|
174
176
|
enablePaigyTools
|
package/dist/enable.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
PAIGY_TOOL_IDS,
|
|
4
|
+
enablePaigyTools
|
|
5
|
+
} from "./chunk-SASREQWV.js";
|
|
6
|
+
import "./chunk-E3OWSUGJ.js";
|
|
7
|
+
|
|
8
|
+
// src/enable.ts
|
|
9
|
+
function main() {
|
|
10
|
+
const argv = process.argv.slice(2);
|
|
11
|
+
let scope = "user";
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const arg = argv[i];
|
|
14
|
+
if (arg === "-h" || arg === "--help") {
|
|
15
|
+
console.log("usage: paigy-enable-tools [--scope user|project]");
|
|
16
|
+
console.log("");
|
|
17
|
+
console.log("Allowlist Paigy's notify/await tools in Claude Code so they run without an");
|
|
18
|
+
console.log("approval prompt each time \u2014 an unattended session can't stall on a dialog.");
|
|
19
|
+
console.log("");
|
|
20
|
+
console.log(" --scope user ~/.claude/settings.json (default) \u2014 every project");
|
|
21
|
+
console.log(" --scope project ./.claude/settings.json \u2014 just this repo");
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log(`Adds: ${PAIGY_TOOL_IDS.join(", ")}`);
|
|
24
|
+
console.log("Leaves pair/unpair out, so they stay human-approved. Safe to re-run.");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (arg === "--scope") {
|
|
28
|
+
const v = argv[++i];
|
|
29
|
+
if (v !== "user" && v !== "project") {
|
|
30
|
+
console.error(`paigy-enable-tools: --scope must be user or project, got ${v ?? "nothing"}`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
scope = v;
|
|
34
|
+
} else {
|
|
35
|
+
console.error(`paigy-enable-tools: unknown argument ${arg}`);
|
|
36
|
+
console.error("usage: paigy-enable-tools [--scope user|project]");
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const result = enablePaigyTools(scope);
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
console.error(`\u2717 ${result.reason}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
if (result.added.length) {
|
|
46
|
+
console.log(`\u2713 allowlisted ${result.added.length} Paigy tool${result.added.length === 1 ? "" : "s"} in ${result.path}`);
|
|
47
|
+
for (const id of result.added) console.log(` ${id}`);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(`\u2713 already allowlisted in ${result.path} \u2014 nothing to do`);
|
|
50
|
+
}
|
|
51
|
+
if (result.already.length && result.added.length) {
|
|
52
|
+
console.log(` (${result.already.length} already present)`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
main();
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
HandoffSchema
|
|
4
|
-
|
|
3
|
+
HandoffSchema,
|
|
4
|
+
MISSED_CALL_PLAN
|
|
5
|
+
} from "./chunk-JT7I3EGY.js";
|
|
5
6
|
import {
|
|
7
|
+
ENABLE_COMMAND,
|
|
6
8
|
PAIGY_TOOL_IDS,
|
|
7
9
|
autoConfigureClients,
|
|
8
10
|
claudeInstallHint,
|
|
9
|
-
enablePaigyTools,
|
|
10
11
|
paigyToolsAllowlisted
|
|
11
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-SASREQWV.js";
|
|
12
13
|
import {
|
|
13
14
|
clearSurface,
|
|
14
15
|
writeSurface
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
startE2ee,
|
|
51
52
|
submitNotification,
|
|
52
53
|
whoAmI
|
|
53
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-E3OWSUGJ.js";
|
|
54
55
|
|
|
55
56
|
// src/index.ts
|
|
56
57
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -89,12 +90,20 @@ function json(s) {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
// src/toolset.ts
|
|
93
|
+
var fmtMin = (m) => m >= 60 ? `${m / 60} hr` : `${m} min`;
|
|
94
|
+
var STANDARD_MEANS = (() => {
|
|
95
|
+
const plan = MISSED_CALL_PLAN.backoff_standard;
|
|
96
|
+
const mins = plan.kind === "at" ? plan.minutes : [];
|
|
97
|
+
const parts = mins.map(fmtMin);
|
|
98
|
+
const list = parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}` : parts[0] ?? "";
|
|
99
|
+
return `Rings again ${list} after the missed call, then leaves it in your inbox`;
|
|
100
|
+
})();
|
|
92
101
|
var CONTACT_SCHEMA = {
|
|
93
102
|
type: "object",
|
|
94
103
|
properties: {
|
|
95
104
|
ask: {
|
|
96
105
|
type: "string",
|
|
97
|
-
description:
|
|
106
|
+
description: `What to tell the user, or what you need to find out from them. Plain prose \u2014 as long as it needs to be (up to 10k characters); Paigy splits it into topics and reads back a few sentences at a time, so do NOT compress a briefing into one line. May be spoken aloud on a call, so write natural speech and name things (not IDs). Contact at exactly two moments: BLOCKED on a decision only they can make, or DONE (one short report \u2014 what shipped, how you verified it, what you flagged). DONE IS SAID ONCE: "all set", "nothing open on my end", "that thread is complete" are the same report in new words, and each one reaches them separately (live 2026-08-12: three of them in three minutes). After the first, you are finished speaking; if they acknowledge it, stop rather than confirming the acknowledgement. Progress is never a contact: set_task_state carries it, and working narration stays in your own terminal \u2014 the user sees you're working without being interrupted by it.`
|
|
98
107
|
},
|
|
99
108
|
waiting: {
|
|
100
109
|
type: "string",
|
|
@@ -129,8 +138,9 @@ var CONTACT_SCHEMA = {
|
|
|
129
138
|
},
|
|
130
139
|
required: ["ask"]
|
|
131
140
|
};
|
|
132
|
-
var CONTACT_DESCRIPTION =
|
|
133
|
-
var ONBOARD_DESCRIPTION = "Get this agent talking to Paigy \u2014 call it FIRST, before contact/await_reply, and any time you're unsure who you are. One call, and it does whatever the situation needs: NOT SET UP \u2192 hatches an identity instantly if this machine holds a device credential (the user ran the Paigy desktop app or harness), otherwise starts the code ceremony; ALREADY SET UP \u2192 returns your current identity and offers the two things left to decide, renaming it or unpairing; TOKEN NO LONGER VALID \u2192 says so, then re-pairs. Pass { name, voice } to choose who you are when hatching, or to RENAME yourself when already set up (voices: rachel, george, jessica, brian, lily). Safe to call any time: idempotent, and it never writes settings \u2014 the tool-allowlist state it reports is read-only. If it returns a `user_code`, print it to the user immediately and call onboard again with the `device_code`. If it returns `
|
|
141
|
+
var CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId } \u2014 pass notificationId to await_reply for the answer, parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with 2-4 options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim.`;
|
|
142
|
+
var ONBOARD_DESCRIPTION = "Get this agent talking to Paigy \u2014 call it FIRST, before contact/await_reply, and any time you're unsure who you are. One call, and it does whatever the situation needs: NOT SET UP \u2192 hatches an identity instantly if this machine holds a device credential (the user ran the Paigy desktop app or harness), otherwise starts the code ceremony; ALREADY SET UP \u2192 returns your current identity and offers the two things left to decide, renaming it or unpairing; TOKEN NO LONGER VALID \u2192 says so, then re-pairs. Pass { name, voice } to choose who you are when hatching, or to RENAME yourself when already set up (voices: rachel, george, jessica, brian, lily). Safe to call any time: idempotent, and it never writes settings \u2014 the tool-allowlist state it reports is read-only. If it returns a `user_code`, print it to the user immediately and call onboard again with the `device_code`. If it returns `enable_command`, PRINT that command for the user to run \u2014 you cannot apply it yourself (it writes your own permission allowlist, which hosts block as privilege escalation), so print it, don't wait for it, and carry on.";
|
|
143
|
+
var SERVER_INSTRUCTIONS = "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose parentId you don't recognize, or one carrying a contextParentId, means the user is resuming or seeding a past conversation \u2014 call get_thread on it FIRST and treat the transcript as prior conversation, not new input. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without contact + await_reply \u2014 WHEN THEY ARE NOT THERE TO ASK. In an interactive session with the user at the terminal, the prompt IS the channel: ending your turn with the question already reaches them, faster and richer than a push, so do NOT send one for an ordinary blocked-or-done moment. Reach for Paigy exactly when the terminal is not enough \u2014 work that will run more than a few minutes unattended (a long build, a deploy, a background job, a cloud session), anything you finish or get stuck on while they are away, or when they have said they are stepping out. If you cannot tell which situation you are in, ask them once, in passing, how they want to be reached, and follow that for the rest of the session. When you need a decision or input, WRITE THE QUESTION and Paigy derives the answer shape from it \u2014 there is no shape parameter to set, and passing one is an error. Ask a yes/no question and they get yes/no; ask them to approve an action and they get approve/deny; ask them to pick, and to pick several, and to rank, and each gets the control it needs. So phrase the ask precisely: 'which of these should I do first' and 'should I do this' are different questions and become different answers. The one thing the prose can't supply is the CHOICES themselves \u2014 when you're asking them to pick between concrete alternatives, pass `options` (2-4 of them, drawn from your own sentence), because Paigy will not invent alternatives it can't know. On a { kind: 'clarify' } reply, see contact's own description for how to respond. When you send waiting:'hard' (or the user asked you to call), remember the ask may be spoken aloud \u2014 write it short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. NEVER go quietly idle while something might still be pending for you: whenever you end a turn with any Paigy notification unanswered (or any chance the user replied through the app while you worked), schedule your own ~2-minute wake-up (harness ScheduleWakeup or equivalent) and call check_replies when it fires; if still nothing, re-schedule and keep looping until resolved or the user says stop. For legibility, always use this exact wording \u2014 reason: 'Paigy idle check \u2014 waiting on <thing>', wake-up prompt: 'Paigy idle check: call check_replies and engage with anything unacknowledged; if idle, re-schedule (~2min).' \u2014 so the user can recognize every idle check at a glance. This self-polling in your own live session (full context intact) is the PRIMARY mechanism; the plugin's Stop hooks are only the dead-session safety net.";
|
|
134
144
|
|
|
135
145
|
// src/pairing.ts
|
|
136
146
|
async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
|
|
@@ -257,9 +267,6 @@ var GetThreadSchema = z.object({
|
|
|
257
267
|
var SearchThreadsSchema = z.object({
|
|
258
268
|
q: z.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
|
|
259
269
|
});
|
|
260
|
-
var EnableToolsSchema = z.object({
|
|
261
|
-
scope: z.enum(["user", "project"]).optional().describe("'user' (default) allowlists Paigy's tools for every project (~/.claude/settings.json); 'project' scopes them to this repo (.claude/settings.json).")
|
|
262
|
-
});
|
|
263
270
|
var SetTaskStateToolSchema = z.object({
|
|
264
271
|
notificationId: z.string(),
|
|
265
272
|
state: SetTaskStateSchema.shape.state
|
|
@@ -308,8 +315,9 @@ function pairedResult(token, sas, note, e2ee) {
|
|
|
308
315
|
} catch {
|
|
309
316
|
}
|
|
310
317
|
base.enable_tool_ids = PAIGY_TOOL_IDS;
|
|
311
|
-
base.
|
|
312
|
-
base.
|
|
318
|
+
base.enable_command = ENABLE_COMMAND;
|
|
319
|
+
base.enable_prompt = "REQUIRED \u2014 before you do anything else, print `enable_command` to the user and say: \"Run this to let Paigy's tools work without an approval prompt each time \u2014 it only allowlists the notify/await tools; pair and unpair still ask.\" Print it every time you pair; don't skip it.";
|
|
320
|
+
base.enable_apply = "You cannot apply this yourself, and must not try: it writes YOUR OWN permission allowlist, which hosts block as privilege escalation no matter what the user said here. PRINT the command and move on \u2014 don't wait for them to run it, and don't re-ask. Paigy's tools still work meanwhile; they just prompt for approval each time until the user runs it. If the host isn't Claude Code, tell them to add `enable_tool_ids` to that tool's own allowlist instead, merging \u2014 never clobbering.";
|
|
313
321
|
return { content: [{ type: "text", text: JSON.stringify(base) }] };
|
|
314
322
|
}
|
|
315
323
|
function awaitingConfirmResult(sas, device_code) {
|
|
@@ -359,7 +367,7 @@ function unidentifiedResult() {
|
|
|
359
367
|
return { isError: true, content: [{ type: "text", text: JSON.stringify({
|
|
360
368
|
error: "no_identity",
|
|
361
369
|
slot: AGENT_NAME,
|
|
362
|
-
message: `This session has no Paigy identity yet, so nothing was sent \u2014 it will not speak as another session. This machine
|
|
370
|
+
message: `This session has no Paigy identity yet, so nothing was sent \u2014 it will not speak as another session. This machine holds a device credential but hatching under it just failed (it may have been revoked), so call \`pair\` (no arguments) to set this session up, then retry \u2014 it re-hatches if the credential recovered and runs the code ceremony if it didn't. To reuse an existing identity instead, start the session with PAIGY_AGENT set to its slot (${listSlots().filter((s) => s !== "Desktop").join(", ") || "none yet"}).`
|
|
363
371
|
}) }] };
|
|
364
372
|
}
|
|
365
373
|
function pairStartResult(start) {
|
|
@@ -388,29 +396,34 @@ var server = new Server(
|
|
|
388
396
|
{ name: "paigy", version: "0.0.0" },
|
|
389
397
|
{
|
|
390
398
|
capabilities: { tools: {} },
|
|
391
|
-
instructions:
|
|
399
|
+
instructions: SERVER_INSTRUCTIONS
|
|
392
400
|
}
|
|
393
401
|
);
|
|
394
402
|
function prependNote(result, note) {
|
|
395
403
|
return { content: [{ type: "text", text: note }, ...result.content] };
|
|
396
404
|
}
|
|
405
|
+
async function hatchUnderDevice(name, voice) {
|
|
406
|
+
if (!listSlots().includes("Desktop")) return null;
|
|
407
|
+
overrideToken(readToken("Desktop"));
|
|
408
|
+
try {
|
|
409
|
+
const minted = await hatch(name ?? suggestedAgentName() ?? "Agent", voice ?? null);
|
|
410
|
+
const dt = { ok: true, access_token: minted.token, name: minted.name, device: null };
|
|
411
|
+
saveToken(dt);
|
|
412
|
+
return dt;
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
} finally {
|
|
416
|
+
overrideToken(null);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
397
419
|
async function runPair(device_code, name, voice, note) {
|
|
398
|
-
if (!device_code
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return pairedResult(
|
|
406
|
-
dt,
|
|
407
|
-
void 0,
|
|
408
|
-
"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 }.")
|
|
409
|
-
);
|
|
410
|
-
} catch {
|
|
411
|
-
} finally {
|
|
412
|
-
overrideToken(null);
|
|
413
|
-
}
|
|
420
|
+
if (!device_code) {
|
|
421
|
+
const dt = await hatchUnderDevice(name, voice);
|
|
422
|
+
if (dt) return pairedResult(
|
|
423
|
+
dt,
|
|
424
|
+
void 0,
|
|
425
|
+
"Hatched instantly under this device's credential \u2014 no code needed. " + (name ? "" : `You were given a default name ("${dt.name}") \u2014 the user never chose it, so don't announce it as their agent's identity. They can rename it in the app, or you can re-call pair with { name, voice }.`)
|
|
426
|
+
);
|
|
414
427
|
}
|
|
415
428
|
if (!device_code) {
|
|
416
429
|
const started2 = pairStartResult(await startPairing(suggestedAgentName()));
|
|
@@ -440,7 +453,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
440
453
|
},
|
|
441
454
|
{
|
|
442
455
|
name: "pair",
|
|
443
|
-
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
|
|
456
|
+
description: "Pair this agent with the user's Paigy account (one-time) \u2014 required before contact/await_reply work. FAST PATH: if this machine already holds a device credential (the user ran the Paigy desktop harness or app), calling pair hatches a fresh identity INSTANTLY \u2014 no code, no approval. Pass { name, voice } to choose who you are (pick your own; voices: rachel, george, jessica, brian, lily). Only when no device credential exists does the code ceremony below run. It does NOT open a browser; the user enters the code in the Paigy app (or scans `qr`). Step 1: call with NO args \u2014 returns { user_code, device_code, qr, user_message } AND starts polling for approval in the background. REQUIRED: You MUST immediately print the `user_message` (the bare code) as a text message to the user, AND in that same turn call step 2 (pair with the device_code). This ensures the user sees the code in chat while the tool blocks/polls in the background for approval. Step 2: call with that device_code to collect the result. Because approval is already being polled in the background, this returns the moment the user approves; on { status:'pending' } just call again to keep waiting; on { status:'awaiting_confirmation' } (E2EE) show the bare `user_message` verify code and call again to finish. The leading text block of every result states the code plainly, so it shows even if you emit no prose. On { status:'paired' } ALWAYS follow the `enable_prompt` \u2014 PRINT the returned `enable_command` so the user can allowlist Paigy's tools and notify/await stop prompting each time. Printing is the whole job: that command writes your own permission allowlist, so you must not run it and a host will block you if you try.",
|
|
444
457
|
inputSchema: json(PairSchema)
|
|
445
458
|
},
|
|
446
459
|
{
|
|
@@ -448,11 +461,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
448
461
|
description: "Log out / unpair this agent from the user's Paigy account: revokes the token server-side (it stops working everywhere) and deletes the local ~/.paigy/token.json. Takes no arguments. After this, contact/await_reply won't work until the user pairs again with the pair tool.",
|
|
449
462
|
inputSchema: json(z.object({}))
|
|
450
463
|
},
|
|
451
|
-
{
|
|
452
|
-
name: "enable_tools",
|
|
453
|
-
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.",
|
|
454
|
-
inputSchema: json(EnableToolsSchema)
|
|
455
|
-
},
|
|
456
464
|
{
|
|
457
465
|
name: "await_reply",
|
|
458
466
|
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from contact). This is how you wait for your answer in-context. Polls ~45s per call \u2014 deliberately under the 60s cap most hosts put on a single tool call, so it ALWAYS returns you something (raise it with maxWaitSeconds only if you know your host allows longer). Returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (this window ended, no answer yet). While your contact is being handled on a LIVE call, you may receive { type:'partial', inFlight:true, turn } results: what the user said to each turn, as they say it. Use partials to PREPARE \u2014 fetch the data, draft the thing, warm the build \u2014 never to act irreversibly: the user can still revise any of them until the final reply arrives. Partial = intelligence, settled = authorization. If a partial's acts carry a question aimed at you and you know the answer, call contact on the SAME parentId right away \u2014 the caller hears your answer on the same call instead of waiting for a callback. Keep calling await_reply until you get the final reply \u2014 THAT one is the decision. On idle, if this is genuinely still blocking you and you have nothing else useful to do meanwhile, just call await_reply again immediately \u2014 keep looping. This is how you actually deliver on the point of calling: the user steps away for a while and comes back to find you'd already continued the moment they answered, not idle waiting to be checked on. Don't give up after one window. Only stop looping to do other work (and check back later), or after an unreasonably long stretch (tens of minutes to hours) worth telling the user about instead. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications, so concurrent contact calls don't cross. A CALL answer can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a blocking question'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's parentId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry `intents` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' (\"call me after lunch\") \u2192 register it NOW with schedule_callback \u2014 when the intent carries `dueInSeconds` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' (\"you pick\") \u2192 make the call yourself and tell them what you chose; 'channel' (\"text me next time\") \u2192 honor it on your next contact (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped \"should I call you back?\" because the follow-up is implied. `transcript` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions (\"yes, IF tests pass\") before acting. If your ask declared `points`, the reply carries `covered` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (contact on the same parentId) or proceed knowingly partial; never treat a partial answer as complete.",
|
|
@@ -460,7 +468,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
460
468
|
},
|
|
461
469
|
{
|
|
462
470
|
name: "check_replies",
|
|
463
|
-
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.",
|
|
471
|
+
description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, parentId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. Also returns `threads` \u2014 the SAME replies + requests grouped by conversation, oldest thread first, each with a `busy` flag and its `items` in arrival order. WORK ONE THREAD AT A TIME: take the oldest thread whose `busy` is false, handle ALL of its items together in a single turn (one set_task_state), then go to the next \u2014 don't interleave threads item-by-item. A `busy` thread already has a turn in progress; leave it and let its new items ride the next turn. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a contact call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with contact on its parentId. EVERY Paigy reply \u2014 this one, await_reply's, and contact's \u2014 may carry `also`: work assigned to you that no wake could reach, handed to you because you happened to be here. It is NOT what you asked about and it is never urgent: FINISH what you came for first, then take it up. Each entry has a `noteId` and the owner's own words; report on its `parentId` thread when it has one, and call set_task_state on that thread as you would for any assigned work. Ignoring it costs nothing \u2014 it rides your next reply too. Also returns `stalled`: work (either direction) you reported in_progress via set_task_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. 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.",
|
|
464
472
|
inputSchema: json(z.object({}))
|
|
465
473
|
},
|
|
466
474
|
{
|
|
@@ -501,6 +509,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
501
509
|
return await handleTool(request, extra?.signal);
|
|
502
510
|
} catch (e) {
|
|
503
511
|
if (e instanceof UnpairedError) {
|
|
512
|
+
const IDENTITY_TOOLS = /* @__PURE__ */ new Set(["pair", "onboard", "unpair"]);
|
|
513
|
+
const hatched = IDENTITY_TOOLS.has(request.params.name) ? null : await hatchUnderDevice();
|
|
514
|
+
if (hatched) return await handleTool(request, extra?.signal);
|
|
504
515
|
if (listSlots().includes("Desktop")) return unidentifiedResult();
|
|
505
516
|
return pairStartResult(await startPairing(suggestedAgentName()));
|
|
506
517
|
}
|
|
@@ -571,7 +582,8 @@ async function handleTool(request, signal) {
|
|
|
571
582
|
tools_allowlisted: allowlisted,
|
|
572
583
|
...allowlisted ? {} : {
|
|
573
584
|
enable_tool_ids: PAIGY_TOOL_IDS,
|
|
574
|
-
|
|
585
|
+
enable_command: ENABLE_COMMAND,
|
|
586
|
+
enable_prompt: "Print `enable_command` to the user: \"Run this to let Paigy's tools work without an approval prompt each time \u2014 it only allowlists the notify/await tools; pair and unpair still ask.\" You cannot run it yourself (it writes your own permission allowlist, which hosts block as privilege escalation) \u2014 print it, don't wait for it, and carry on."
|
|
575
587
|
},
|
|
576
588
|
// Setup is done, so the only things left are the user's to decide. Named here
|
|
577
589
|
// rather than left implicit: an agent that reports "ready" and stops gives the
|
|
@@ -613,12 +625,6 @@ async function handleTool(request, signal) {
|
|
|
613
625
|
}]
|
|
614
626
|
};
|
|
615
627
|
}
|
|
616
|
-
case "enable_tools": {
|
|
617
|
-
const { scope } = EnableToolsSchema.parse(request.params.arguments ?? {});
|
|
618
|
-
const result = enablePaigyTools(scope ?? "user");
|
|
619
|
-
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;
|
|
620
|
-
return { content: [{ type: "text", text: JSON.stringify({ ...result, message }) }] };
|
|
621
|
-
}
|
|
622
628
|
// `contact` (#575) is the one LISTED name; `notify` (#496) and `notify_user`
|
|
623
629
|
// (the original) are HIDDEN aliases — unlisted but accepted, so stale prompts,
|
|
624
630
|
// hooks, and cached 0.24 servers' habits keep working. Same handler, same wire.
|
|
@@ -697,7 +703,11 @@ async function beat() {
|
|
|
697
703
|
if (beatDown) console.error("paigy: presence restored \u2014 the live dot is honest again");
|
|
698
704
|
beatDown = false;
|
|
699
705
|
} catch (err) {
|
|
700
|
-
if (err instanceof UnpairedError)
|
|
706
|
+
if (err instanceof UnpairedError) {
|
|
707
|
+
if (!beatDown) console.error("paigy: identity unpaired/revoked \u2014 the live dot goes dark on purpose");
|
|
708
|
+
beatDown = true;
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
701
711
|
if (!beatDown) console.error(`paigy: presence beat failed \u2014 the phone may show this agent offline (${String(err)})`);
|
|
702
712
|
beatDown = true;
|
|
703
713
|
}
|
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-JT7I3EGY.js";
|
|
6
6
|
import {
|
|
7
7
|
checkReplies,
|
|
8
8
|
registerDelivery
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-E3OWSUGJ.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-SASREQWV.js";
|
|
7
7
|
import {
|
|
8
8
|
AGENT_NAME,
|
|
9
9
|
TOKEN_PATH,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
setIdentity,
|
|
18
18
|
sleep,
|
|
19
19
|
whoAmI
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-E3OWSUGJ.js";
|
|
21
21
|
|
|
22
22
|
// src/onboard.ts
|
|
23
23
|
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.33.0",
|
|
4
|
+
"description": "Paigy MCP server — the AI agent harness that calls you. Lets an agent notify a user and await their reply.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"paigy-mcp": "./dist/index.js",
|
|
10
10
|
"paigy-mcp-onboard": "./dist/onboard.js",
|
|
11
11
|
"paigy-listen": "./dist/listen.js",
|
|
12
|
-
"paigy-statusline": "./dist/statusline.js"
|
|
12
|
+
"paigy-statusline": "./dist/statusline.js",
|
|
13
|
+
"paigy-enable-tools": "./dist/enable.js"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"dist"
|