@paigy/mcp 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -2280,14 +2280,28 @@ var CHUNK_MAX = 300;
2280
2280
  var ASK_MAX = 1e4;
2281
2281
  var OPTION_MAX = 80;
2282
2282
  var NEEDS_MAX = 6;
2283
- var UNSPEAKABLE = /```|\n/;
2283
+ var UNSPEAKABLE = /```/;
2284
+ function normalizeSpeech(req) {
2285
+ const flat = (s) => s.replace(/\s+/g, " ").trim();
2286
+ return {
2287
+ ...req,
2288
+ ...req.ask !== void 0 ? { ask: flat(req.ask) } : {},
2289
+ ...req.context ? {
2290
+ context: {
2291
+ ...req.context,
2292
+ title: flat(req.context.title),
2293
+ description: req.context.description.map(flat)
2294
+ }
2295
+ } : {}
2296
+ };
2297
+ }
2284
2298
  function lintNotify(req) {
2285
2299
  const problems = [];
2286
2300
  if (req.ask !== void 0) {
2287
2301
  if (req.ask.length > ASK_MAX)
2288
2302
  problems.push(`ask is ${req.ask.length} chars \u2014 state the need and why it matters now in \u2264${ASK_MAX}; move detail into a smaller follow-up`);
2289
2303
  if (UNSPEAKABLE.test(req.ask))
2290
- problems.push("ask contains code fences or newlines \u2014 write it as plain prose (it may be read aloud on a call)");
2304
+ problems.push("ask contains a code fence \u2014 write it as plain prose (it may be read aloud on a call)");
2291
2305
  for (const n of req.needs ?? []) {
2292
2306
  if (n.length > OPTION_MAX) problems.push(`need "${n.slice(0, 40)}\u2026" is too long \u2014 each need is a short phrase (\u2264${OPTION_MAX} chars)`);
2293
2307
  }
@@ -2300,7 +2314,7 @@ function lintNotify(req) {
2300
2314
  if (req.context.title.length > TITLE_MAX)
2301
2315
  problems.push(`title is ${req.context.title.length} chars \u2014 shorten to \u2264${TITLE_MAX} (it's what shows on the banner / gets spoken on a ring)`);
2302
2316
  if (UNSPEAKABLE.test(req.context.title))
2303
- problems.push("title contains code fences or newlines \u2014 one plain-prose line");
2317
+ problems.push("title contains a code fence \u2014 one plain-prose line");
2304
2318
  if (req.context.description.length > CHUNKS_MAX)
2305
2319
  problems.push(`${req.context.description.length} description chunks \u2014 cap at ${CHUNKS_MAX}; merge or drop the rest`);
2306
2320
  for (const [i, chunk] of req.context.description.entries()) {
@@ -2308,7 +2322,7 @@ function lintNotify(req) {
2308
2322
  problems.push(`description[${i}] is ${chunk.length} chars \u2014 split it into standalone points of \u2264${CHUNK_MAX}`);
2309
2323
  }
2310
2324
  if (spoken && UNSPEAKABLE.test(req.context.description.join(" ")))
2311
- problems.push("urgency is 'call'/'banner' but the description has code fences/newlines-in-chunk \u2014 rewrite in spoken register (it will be read aloud)");
2325
+ problems.push("urgency is 'call'/'banner' but the description has a code fence \u2014 rewrite in spoken register (it will be read aloud)");
2312
2326
  }
2313
2327
  for (const o of req.options ?? []) {
2314
2328
  if (o.label.length > OPTION_MAX)
@@ -2336,7 +2350,7 @@ var TransformSchema = z.enum([
2336
2350
  "coalesce",
2337
2351
  // many bundles → one — morning triage (#347), threading-supersede, digest
2338
2352
  "organize",
2339
- // group related bundles onto one thread — threading (`threadId`), parent/clarify links
2353
+ // group related bundles onto one thread — threading (`parentId`), parent/clarify links
2340
2354
  "summarize"
2341
2355
  // reduce volume, keep decision value — 30-turn cap, spoken briefing
2342
2356
  ]);
@@ -2406,13 +2420,19 @@ var NotifyRequestSchema = z.object({
2406
2420
  repo: z.string().optional(),
2407
2421
  /** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
2408
2422
  branch: z.string().optional(),
2409
- /** Continue an existing conversation; omitted = start a new thread. */
2410
- threadId: z.string().uuid().optional(),
2423
+ /** Continue an existing conversation — the id of any notification in it (its root
2424
+ * is the conversation's identity). Omitted = start a new conversation. Renamed
2425
+ * from `parentId` (2026-08-03): one linkage system, the parent; the API edge
2426
+ * still accepts the old name from older clients. */
2427
+ parentId: z.string().uuid().optional(),
2411
2428
  urgency: NotifyLevelSchema.default("inbox").describe(
2412
2429
  "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
2430
  ),
2414
- /** The request this one was spawned from, for a clarification. */
2415
- parentId: z.string().optional(),
2431
+ /** The request this one CLARIFIES — spawning a clarification keeps that parent
2432
+ * visible and marks it needs_input. Renamed from the old `parentId` (2026-08-03)
2433
+ * when `parentId` became the conversation handle: `parentId` says WHERE, this
2434
+ * says HOW. */
2435
+ clarifies: z.string().optional(),
2416
2436
  /** E2EE (text lane): when the pairing is E2EE, the sealed replacements for the
2417
2437
  * plaintext content fields, keyed by field name. FINALIZED wire shape (was
2418
2438
  * provisional in the storage PR): a per-field map `{ context?, options?,
@@ -2570,25 +2590,35 @@ var UserAnswerSchema = z.discriminatedUnion("kind", [
2570
2590
  z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()), labels: z.array(z.string()).optional() }),
2571
2591
  z.object({ kind: z.literal("clarify"), chunks: z.array(z.string()).min(1) }),
2572
2592
  z.object({ kind: z.literal("confirm"), approved: z.boolean() }),
2573
- z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
2593
+ z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) }),
2594
+ /** An auto-answer derived from the user's PAST decisions (broker/precedent-design.md §2):
2595
+ * delivered through the same settle/await path as a human answer, carrying the judge's
2596
+ * derivation and the precedent ids it grew from. Always paired with a visible trail
2597
+ * card the user can reply to — the broker never overrides the user. */
2598
+ z.object({ kind: z.literal("precedent"), answer: z.string(), derivation: z.string(), sources: z.array(z.string()).min(1) })
2574
2599
  ]);
2575
2600
  var IntentSchema = z.object({
2576
2601
  // The full vocabulary the bot's mapper emits (mapper.INTENT_KINDS) — the schema lagged
2577
2602
  // it by two ("detail", "feedback"), and because the settle handler parsed the array
2578
2603
  // all-or-nothing, ONE feedback act silently dropped EVERY intent on the call,
2579
2604
  // questions included. Found auditing five calls' stored feedback, 2026-08-01.
2580
- kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command"]),
2605
+ kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command", "control"]),
2581
2606
  detail: z.string(),
2582
2607
  /** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
2583
2608
  * "after lunch") against the agent machine's clock — the user's — and attaches
2584
2609
  * the seconds, ready to pass straight to schedule_callback. Absent when the
2585
2610
  * detail didn't parse (the agent interprets it) or the kind isn't defer. */
2586
- dueInSeconds: z.number().int().positive().optional()
2611
+ dueInSeconds: z.number().int().positive().optional(),
2612
+ /** Feedback only (#812): WHICH failure the complaint names — typed by the mapper that
2613
+ * already read the utterance, so `feedback_from_call.kind` stops defaulting to
2614
+ * 'other' on every row. A table that records that something was wrong and nothing
2615
+ * about what cannot answer "is the bot looping less this week?". */
2616
+ fault: z.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
2587
2617
  });
2588
2618
  var AwaitItemSchema = z.discriminatedUnion("type", [
2589
2619
  z.object({
2590
2620
  type: z.literal("reply"),
2591
- threadId: z.string(),
2621
+ parentId: z.string(),
2592
2622
  notificationId: z.string(),
2593
2623
  answer: UserAnswerSchema,
2594
2624
  /** E2EE: present when the answer is sealed. The server relays the opaque answer
@@ -2609,7 +2639,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
2609
2639
  }),
2610
2640
  z.object({
2611
2641
  type: z.literal("remind"),
2612
- threadId: z.string(),
2642
+ parentId: z.string(),
2613
2643
  notificationId: z.string(),
2614
2644
  remindAt: z.string().datetime({ offset: true }),
2615
2645
  /** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
@@ -2621,7 +2651,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
2621
2651
  * re-orient via get_thread / check_replies). */
2622
2652
  z.object({
2623
2653
  type: z.literal("superseded"),
2624
- threadId: z.string(),
2654
+ parentId: z.string(),
2625
2655
  notificationId: z.string()
2626
2656
  }),
2627
2657
  /** A LIVE call's turn, streamed as it lands (#783). PROVISIONAL: the user can still
@@ -2644,7 +2674,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
2644
2674
  ]);
2645
2675
  var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
2646
2676
  var ScheduleCallbackSchema = z.object({
2647
- threadId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
2677
+ parentId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
2648
2678
  trigger: CallbackTriggerSchema,
2649
2679
  dueInSeconds: z.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
2650
2680
  note: z.string().optional().describe("What to tell the user when you follow up.")
@@ -2652,7 +2682,7 @@ var ScheduleCallbackSchema = z.object({
2652
2682
  var PendingRepliesSchema = z.object({
2653
2683
  replies: z.array(
2654
2684
  z.object({
2655
- threadId: z.string(),
2685
+ parentId: z.string(),
2656
2686
  notificationId: z.string(),
2657
2687
  answer: UserAnswerSchema,
2658
2688
  /** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the
@@ -2669,33 +2699,33 @@ var PendingRepliesSchema = z.object({
2669
2699
  })
2670
2700
  ),
2671
2701
  pending: z.array(
2672
- z.object({ threadId: z.string(), notificationId: z.string(), createdAt: z.string() })
2702
+ z.object({ parentId: z.string(), notificationId: z.string(), createdAt: z.string() })
2673
2703
  ),
2674
2704
  /** User-initiated requests addressed to this agent; act on them and reply via
2675
- * contact on the same threadId. Keeps reappearing until you call
2705
+ * contact on the same parentId. Keeps reappearing until you call
2676
2706
  * set_task_state on its notificationId. */
2677
2707
  requests: z.array(
2678
2708
  z.object({
2679
- threadId: z.string(),
2709
+ parentId: z.string(),
2680
2710
  notificationId: z.string(),
2681
2711
  text: z.string(),
2682
2712
  createdAt: z.string(),
2683
2713
  /** The user seeded this request with a past conversation — call get_thread on it
2684
2714
  * FIRST and treat the transcript as prior context (#57/#251). */
2685
- contextThreadId: z.string().optional()
2715
+ contextParentId: z.string().optional()
2686
2716
  })
2687
2717
  ),
2688
2718
  /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
2689
2719
  * if blocked, or at a time that has passed). Re-surfaced every sweep until you
2690
- * fulfill one by calling contact on its threadId. */
2720
+ * fulfill one by calling contact on its parentId. */
2691
2721
  owedCallbacks: z.array(
2692
- z.object({ threadId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
2722
+ z.object({ parentId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
2693
2723
  ),
2694
2724
  /** Work (either direction) you reported in_progress a while ago and never reported
2695
2725
  * completed — likely left half-done by this session or a prior one that crashed or
2696
2726
  * went idle. Report a real state (set_task_state) or continue the work. */
2697
2727
  stalled: z.array(
2698
- z.object({ threadId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
2728
+ z.object({ parentId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
2699
2729
  ),
2700
2730
  /** The queue rail (#614, pending/design.md): the same replies + requests, grouped by
2701
2731
  * thread and ordered oldest-thread-first, so you work ONE thread at a time — fold all of
@@ -2706,7 +2736,7 @@ var PendingRepliesSchema = z.object({
2706
2736
  * notificationId). Derived, never stored — a crashed agent recomputes it exactly. */
2707
2737
  threads: z.array(
2708
2738
  z.object({
2709
- threadId: z.string(),
2739
+ parentId: z.string(),
2710
2740
  busy: z.boolean(),
2711
2741
  items: z.array(
2712
2742
  z.object({
@@ -2753,19 +2783,32 @@ var AgendaTurnSchema = z.object({
2753
2783
  claimId: z.string().optional(),
2754
2784
  /** The claim's voice key (#462) — the OUTBOUND identity, audible who-is-asking. */
2755
2785
  voice: z.string().optional(),
2786
+ /** The claim's AGENT NAME (#838) — the spoken identity. A voice alone doesn't say
2787
+ * whose request this is: an item that folded in from another agent arrived as a bare
2788
+ * non-sequitur ("First real production sign-in is yours to make whenever you want.")
2789
+ * and the owner answered "What?". The bot names the agent before its first turn. */
2790
+ agent: z.string().optional(),
2756
2791
  select: SelectShapeSchema.optional(),
2757
2792
  options: z.array(OptionSchema.omit({ id: true })).optional(),
2758
2793
  /** The seat was satisfied by the call's own ACCOUNT (replan-design.md, user_info): the
2759
2794
  * caller already answered this claim in an earlier utterance, quoted here VERBATIM —
2760
2795
  * the bot speaks the turn's short confirmation, posts these words as the claim's
2761
2796
  * answer, and never re-asks. Grounded at parse time: an invented settle is #796. */
2762
- settle: z.string().optional()
2797
+ settle: z.string().optional(),
2798
+ /** Pacing (#826, owner 2026-08-03: "how fast we move through them ... are parameters"):
2799
+ * seconds the floor stays open after this turn speaks. Absent = the bot's defaults
2800
+ * (the beat for context, the answer window for asks). Clamped bot-side. */
2801
+ pace: z.number().positive().optional(),
2802
+ /** Whether the walk WAITS for an answer before moving on. Absent = derived as today
2803
+ * (a question blocks, context flows). blocking:false on a question = ask and move
2804
+ * on, the claim stays pending; blocking:true on context = hold for a reply. */
2805
+ blocking: z.boolean().optional()
2763
2806
  });
2764
2807
  var InboxItemSchema = z.object({
2765
2808
  id: z.string(),
2766
2809
  /** The conversation thread + connection this item lives on. Present on the replied
2767
2810
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
2768
- threadId: z.string().optional(),
2811
+ parentId: z.string().optional(),
2769
2812
  tokenId: z.string().optional(),
2770
2813
  status: NotifyStatusSchema,
2771
2814
  context: ContextSchema,
@@ -2799,7 +2842,7 @@ var InboxItemSchema = z.object({
2799
2842
  * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
2800
2843
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
2801
2844
  error: z.string().optional(),
2802
- parentId: z.string().optional(),
2845
+ clarifies: z.string().optional(),
2803
2846
  select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
2804
2847
  confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
2805
2848
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -2898,7 +2941,7 @@ var UserSettingsSchema = z.object({
2898
2941
  });
2899
2942
  var HistoryItemSchema = z.object({
2900
2943
  id: z.string(),
2901
- threadId: z.string(),
2944
+ parentId: z.string(),
2902
2945
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
2903
2946
  initiator: z.enum(["user", "agent"]),
2904
2947
  title: z.string(),
@@ -2925,6 +2968,16 @@ var ConnectionSummarySchema = z.object({
2925
2968
  /** Most recent notification on this connection, either direction. Null = no contact yet.
2926
2969
  * Drives the agents-page recency grouping (Today / This week / …). */
2927
2970
  lastContactAt: z.string().datetime().nullable(),
2971
+ /** Last presence heartbeat from a running agent process (POST /api/presence) — the
2972
+ * desktop app while open. Null = never seen; stale = offline. */
2973
+ lastSeenAt: z.string().datetime().nullable().optional(),
2974
+ /** What a live desktop can run (companion.md §2.2), advertised on its heartbeat:
2975
+ * harness availabilities + granted workspaces — the option set the phone's
2976
+ * "new session" sheet offers. Absent for ordinary MCP agents. */
2977
+ runtime: z.object({
2978
+ harnesses: z.array(z.object({ name: z.string(), label: z.string(), status: z.string() })).optional(),
2979
+ workspaces: z.array(z.string()).optional()
2980
+ }).optional(),
2928
2981
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
2929
2982
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
2930
2983
  managed: z.boolean()
@@ -2936,15 +2989,15 @@ var CreateRequestSchema = z.object({
2936
2989
  text: z.string().min(1),
2937
2990
  /** Land the request on an existing conversation thread (History → "Continue")
2938
2991
  * instead of minting a fresh one. Must belong to the requesting user. */
2939
- threadId: z.string().optional(),
2992
+ parentId: z.string().optional(),
2940
2993
  /** Point the agent at a past conversation (possibly with a different agent) as
2941
2994
  * starting context (History → "New session from this"). A reference, not a copy —
2942
2995
  * the agent reads it via get_thread. Must belong to the requesting user. */
2943
- contextThreadId: z.string().optional()
2996
+ contextParentId: z.string().optional()
2944
2997
  });
2945
2998
  var HandoffSchema = z.object({
2946
2999
  /** Land the note on an existing thread; omitted mints a fresh one. */
2947
- threadId: z.string().uuid().optional(),
3000
+ parentId: z.string().uuid().optional(),
2948
3001
  /** One-line headline of the working context handed off. */
2949
3002
  title: z.string().min(1),
2950
3003
  /** The brief — standalone notes the successor reads (what was done, what's left, links). */
@@ -2983,7 +3036,7 @@ var NoteSchema = z.object({
2983
3036
  /** Who it was assigned to (a participant ref, 'agent:<tokenId>'); null = unassigned. */
2984
3037
  assignee: z.string().nullable(),
2985
3038
  /** The request thread minted at assignment; null until assigned. */
2986
- threadId: z.string().nullable(),
3039
+ parentId: z.string().nullable(),
2987
3040
  createdAt: z.string()
2988
3041
  });
2989
3042
  var CreateNoteSchema = z.object({
@@ -2999,8 +3052,17 @@ var RecordDecisionSchema = z.object({
2999
3052
  answer: z.string().min(1).max(2e3)
3000
3053
  }).refine((d) => d.decisionId || d.question, { message: "decisionId or question required" });
3001
3054
  var AssignNoteSchema = z.object({
3002
- target: z.string().min(1)
3003
- });
3055
+ /** An EXISTING agent: token id or nickname. Omit when spawning fresh. */
3056
+ target: z.string().min(1).optional(),
3057
+ /** Spawn a NEW session for this note (companion.md §2.2): the assignee doesn't
3058
+ * exist yet — mint it on a live desktop that advertises the harness+workspace,
3059
+ * named after the note. The brief arrives as its opening request. */
3060
+ spawn: z.object({
3061
+ hostTokenId: z.string().uuid(),
3062
+ harness: z.string().min(1),
3063
+ workspace: z.string().min(1)
3064
+ }).optional()
3065
+ }).refine((a) => !!a.target !== !!a.spawn, { message: "exactly one of target or spawn" });
3004
3066
  var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
3005
3067
  var RegisterDeliverySchema = z.object({ mode: DeliveryModeSchema });
3006
3068
  var OAuthStartSchema = z.object({
@@ -3060,7 +3122,7 @@ var DeviceRosterSchema = z.object({
3060
3122
  var WakeNudgeSchema = z.object({
3061
3123
  kind: z.enum(["reply", "request", "callback"]),
3062
3124
  notificationId: z.string().optional(),
3063
- threadId: z.string()
3125
+ parentId: z.string()
3064
3126
  });
3065
3127
  var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
3066
3128
  var PairingRevealSchema = z.object({
@@ -3520,6 +3582,9 @@ function readToken(agent2 = AGENT_NAME) {
3520
3582
  if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
3521
3583
  return readTokenFile()[agent2]?.access_token ?? "";
3522
3584
  }
3585
+ function listSlots() {
3586
+ return Object.keys(readTokenFile());
3587
+ }
3523
3588
  function deleteToken(agent2 = AGENT_NAME) {
3524
3589
  const slots = readTokenFile();
3525
3590
  if (!(agent2 in slots)) return false;
@@ -3725,8 +3790,8 @@ async function sealForE2ee(req, token, deps = {}) {
3725
3790
  const { context: _c, options: _o, visuals: _v, repo: _r, branch: _b, ...meta } = req;
3726
3791
  return { ...meta, envelope };
3727
3792
  }
3728
- async function submitNotification(req) {
3729
- const token = readToken();
3793
+ async function submitNotification(req, opts = {}) {
3794
+ const token = authToken(opts.token) ?? "";
3730
3795
  const body = await sealForE2ee(req, token);
3731
3796
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify`, {
3732
3797
  method: "POST",
@@ -3806,22 +3871,22 @@ function landIntents(intents) {
3806
3871
  return due !== null ? { ...i, dueInSeconds: due } : i;
3807
3872
  });
3808
3873
  }
3809
- async function getThread(threadId) {
3810
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/thread/${encodeURIComponent(threadId)}`, {
3811
- headers: { authorization: `Bearer ${readToken()}` }
3874
+ async function getThread(parentId) {
3875
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/thread/${encodeURIComponent(parentId)}`, {
3876
+ headers: { authorization: `Bearer ${authToken()}` }
3812
3877
  }));
3813
3878
  if (!res.ok) throw new Error(`get_thread failed: ${res.status} ${await res.text()}`);
3814
3879
  return await res.json();
3815
3880
  }
3816
3881
  async function searchThreads(q) {
3817
3882
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/search?q=${encodeURIComponent(q)}`, {
3818
- headers: { authorization: `Bearer ${readToken()}` }
3883
+ headers: { authorization: `Bearer ${authToken()}` }
3819
3884
  }));
3820
3885
  if (!res.ok) throw new Error(`search_threads failed: ${res.status} ${await res.text()}`);
3821
3886
  return await res.json();
3822
3887
  }
3823
- async function checkReplies() {
3824
- const token = readToken();
3888
+ async function checkReplies(opts = {}) {
3889
+ const token = authToken(opts.token);
3825
3890
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/pending`, {
3826
3891
  headers: { authorization: `Bearer ${token}` }
3827
3892
  }));
@@ -3840,16 +3905,30 @@ async function checkReplies() {
3840
3905
  async function answerCallerQuestion(notificationId, answer) {
3841
3906
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/voice/session-answer`, {
3842
3907
  method: "POST",
3843
- headers: { "content-type": "application/json", authorization: `Bearer ${readToken()}` },
3908
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
3844
3909
  body: JSON.stringify({ notificationId, answer })
3845
3910
  }));
3846
3911
  if (!res.ok) throw new Error(`answer_caller_question failed: ${res.status} ${await res.text()}`);
3847
3912
  return await res.json();
3848
3913
  }
3849
- async function setTaskState(notificationId, state) {
3914
+ var tokenOverride = null;
3915
+ function overrideToken(secret) {
3916
+ tokenOverride = secret;
3917
+ }
3918
+ var authToken = (explicit) => explicit ?? tokenOverride ?? readToken();
3919
+ async function hatch(name, voice = null) {
3920
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/hatch`, {
3921
+ method: "POST",
3922
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
3923
+ body: JSON.stringify({ name, voice })
3924
+ }));
3925
+ if (!res.ok) throw new Error(`hatch failed: ${res.status} ${await res.text()}`);
3926
+ return await res.json();
3927
+ }
3928
+ async function setTaskState(notificationId, state, opts = {}) {
3850
3929
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify/${notificationId}/state`, {
3851
3930
  method: "PATCH",
3852
- headers: { "content-type": "application/json", authorization: `Bearer ${readToken()}` },
3931
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
3853
3932
  body: JSON.stringify({ state })
3854
3933
  }));
3855
3934
  if (!res.ok) throw new Error(`set_task_state failed: ${res.status} ${await res.text()}`);
@@ -3858,7 +3937,7 @@ async function setTaskState(notificationId, state) {
3858
3937
  async function registerDelivery(mode) {
3859
3938
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/delivery`, {
3860
3939
  method: "POST",
3861
- headers: { "content-type": "application/json", authorization: `Bearer ${readToken()}` },
3940
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
3862
3941
  body: JSON.stringify({ mode })
3863
3942
  }));
3864
3943
  if (!res.ok) throw new Error(`register_delivery failed: ${res.status} ${await res.text()}`);
@@ -3888,6 +3967,7 @@ async function handoff(req) {
3888
3967
  export {
3889
3968
  BACKEND_URL,
3890
3969
  reach,
3970
+ normalizeSpeech,
3891
3971
  lintNotify,
3892
3972
  NotifyRequestSchema,
3893
3973
  SetTaskStateSchema,
@@ -3897,6 +3977,7 @@ export {
3897
3977
  saveToken,
3898
3978
  sleep,
3899
3979
  readToken,
3980
+ listSlots,
3900
3981
  deleteToken,
3901
3982
  revokeToken,
3902
3983
  requestCode,
@@ -3915,6 +3996,8 @@ export {
3915
3996
  searchThreads,
3916
3997
  checkReplies,
3917
3998
  answerCallerQuestion,
3999
+ overrideToken,
4000
+ hatch,
3918
4001
  setTaskState,
3919
4002
  registerDelivery,
3920
4003
  scheduleCallback,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  AGENT_NAME
3
- } from "./chunk-VCEFF2VA.js";
3
+ } from "./chunk-7P4WMXGB.js";
4
4
 
5
5
  // src/clients.ts
6
6
  import { execFile } from "child_process";
@@ -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 (`threadId`), parent/clarify links
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; omitted = start a new thread. */
94
- threadId: z.string().uuid().optional(),
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 was spawned from, for a clarification. */
99
- parentId: z.string().optional(),
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?,
@@ -209,25 +215,35 @@ var UserAnswerSchema = z.discriminatedUnion("kind", [
209
215
  z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()), labels: z.array(z.string()).optional() }),
210
216
  z.object({ kind: z.literal("clarify"), chunks: z.array(z.string()).min(1) }),
211
217
  z.object({ kind: z.literal("confirm"), approved: z.boolean() }),
212
- z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
218
+ z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) }),
219
+ /** An auto-answer derived from the user's PAST decisions (broker/precedent-design.md §2):
220
+ * delivered through the same settle/await path as a human answer, carrying the judge's
221
+ * derivation and the precedent ids it grew from. Always paired with a visible trail
222
+ * card the user can reply to — the broker never overrides the user. */
223
+ z.object({ kind: z.literal("precedent"), answer: z.string(), derivation: z.string(), sources: z.array(z.string()).min(1) })
213
224
  ]);
214
225
  var IntentSchema = z.object({
215
226
  // The full vocabulary the bot's mapper emits (mapper.INTENT_KINDS) — the schema lagged
216
227
  // it by two ("detail", "feedback"), and because the settle handler parsed the array
217
228
  // all-or-nothing, ONE feedback act silently dropped EVERY intent on the call,
218
229
  // questions included. Found auditing five calls' stored feedback, 2026-08-01.
219
- kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command"]),
230
+ kind: z.enum(["defer", "delegate", "channel", "question", "detail", "feedback", "command", "control"]),
220
231
  detail: z.string(),
221
232
  /** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
222
233
  * "after lunch") against the agent machine's clock — the user's — and attaches
223
234
  * the seconds, ready to pass straight to schedule_callback. Absent when the
224
235
  * detail didn't parse (the agent interprets it) or the kind isn't defer. */
225
- dueInSeconds: z.number().int().positive().optional()
236
+ dueInSeconds: z.number().int().positive().optional(),
237
+ /** Feedback only (#812): WHICH failure the complaint names — typed by the mapper that
238
+ * already read the utterance, so `feedback_from_call.kind` stops defaulting to
239
+ * 'other' on every row. A table that records that something was wrong and nothing
240
+ * about what cannot answer "is the bot looping less this week?". */
241
+ fault: z.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
226
242
  });
227
243
  var AwaitItemSchema = z.discriminatedUnion("type", [
228
244
  z.object({
229
245
  type: z.literal("reply"),
230
- threadId: z.string(),
246
+ parentId: z.string(),
231
247
  notificationId: z.string(),
232
248
  answer: UserAnswerSchema,
233
249
  /** E2EE: present when the answer is sealed. The server relays the opaque answer
@@ -248,7 +264,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
248
264
  }),
249
265
  z.object({
250
266
  type: z.literal("remind"),
251
- threadId: z.string(),
267
+ parentId: z.string(),
252
268
  notificationId: z.string(),
253
269
  remindAt: z.string().datetime({ offset: true }),
254
270
  /** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
@@ -260,7 +276,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
260
276
  * re-orient via get_thread / check_replies). */
261
277
  z.object({
262
278
  type: z.literal("superseded"),
263
- threadId: z.string(),
279
+ parentId: z.string(),
264
280
  notificationId: z.string()
265
281
  }),
266
282
  /** A LIVE call's turn, streamed as it lands (#783). PROVISIONAL: the user can still
@@ -283,7 +299,7 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
283
299
  ]);
284
300
  var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
285
301
  var ScheduleCallbackSchema = z.object({
286
- threadId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
302
+ parentId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
287
303
  trigger: CallbackTriggerSchema,
288
304
  dueInSeconds: z.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
289
305
  note: z.string().optional().describe("What to tell the user when you follow up.")
@@ -291,7 +307,7 @@ var ScheduleCallbackSchema = z.object({
291
307
  var PendingRepliesSchema = z.object({
292
308
  replies: z.array(
293
309
  z.object({
294
- threadId: z.string(),
310
+ parentId: z.string(),
295
311
  notificationId: z.string(),
296
312
  answer: UserAnswerSchema,
297
313
  /** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the
@@ -308,33 +324,33 @@ var PendingRepliesSchema = z.object({
308
324
  })
309
325
  ),
310
326
  pending: z.array(
311
- z.object({ threadId: z.string(), notificationId: z.string(), createdAt: z.string() })
327
+ z.object({ parentId: z.string(), notificationId: z.string(), createdAt: z.string() })
312
328
  ),
313
329
  /** User-initiated requests addressed to this agent; act on them and reply via
314
- * contact on the same threadId. Keeps reappearing until you call
330
+ * contact on the same parentId. Keeps reappearing until you call
315
331
  * set_task_state on its notificationId. */
316
332
  requests: z.array(
317
333
  z.object({
318
- threadId: z.string(),
334
+ parentId: z.string(),
319
335
  notificationId: z.string(),
320
336
  text: z.string(),
321
337
  createdAt: z.string(),
322
338
  /** The user seeded this request with a past conversation — call get_thread on it
323
339
  * FIRST and treat the transcript as prior context (#57/#251). */
324
- contextThreadId: z.string().optional()
340
+ contextParentId: z.string().optional()
325
341
  })
326
342
  ),
327
343
  /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
328
344
  * if blocked, or at a time that has passed). Re-surfaced every sweep until you
329
- * fulfill one by calling contact on its threadId. */
345
+ * fulfill one by calling contact on its parentId. */
330
346
  owedCallbacks: z.array(
331
- z.object({ threadId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
347
+ z.object({ parentId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
332
348
  ),
333
349
  /** Work (either direction) you reported in_progress a while ago and never reported
334
350
  * completed — likely left half-done by this session or a prior one that crashed or
335
351
  * went idle. Report a real state (set_task_state) or continue the work. */
336
352
  stalled: z.array(
337
- z.object({ threadId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
353
+ z.object({ parentId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
338
354
  ),
339
355
  /** The queue rail (#614, pending/design.md): the same replies + requests, grouped by
340
356
  * thread and ordered oldest-thread-first, so you work ONE thread at a time — fold all of
@@ -345,7 +361,7 @@ var PendingRepliesSchema = z.object({
345
361
  * notificationId). Derived, never stored — a crashed agent recomputes it exactly. */
346
362
  threads: z.array(
347
363
  z.object({
348
- threadId: z.string(),
364
+ parentId: z.string(),
349
365
  busy: z.boolean(),
350
366
  items: z.array(
351
367
  z.object({
@@ -392,19 +408,32 @@ var AgendaTurnSchema = z.object({
392
408
  claimId: z.string().optional(),
393
409
  /** The claim's voice key (#462) — the OUTBOUND identity, audible who-is-asking. */
394
410
  voice: z.string().optional(),
411
+ /** The claim's AGENT NAME (#838) — the spoken identity. A voice alone doesn't say
412
+ * whose request this is: an item that folded in from another agent arrived as a bare
413
+ * non-sequitur ("First real production sign-in is yours to make whenever you want.")
414
+ * and the owner answered "What?". The bot names the agent before its first turn. */
415
+ agent: z.string().optional(),
395
416
  select: SelectShapeSchema.optional(),
396
417
  options: z.array(OptionSchema.omit({ id: true })).optional(),
397
418
  /** The seat was satisfied by the call's own ACCOUNT (replan-design.md, user_info): the
398
419
  * caller already answered this claim in an earlier utterance, quoted here VERBATIM —
399
420
  * the bot speaks the turn's short confirmation, posts these words as the claim's
400
421
  * answer, and never re-asks. Grounded at parse time: an invented settle is #796. */
401
- settle: z.string().optional()
422
+ settle: z.string().optional(),
423
+ /** Pacing (#826, owner 2026-08-03: "how fast we move through them ... are parameters"):
424
+ * seconds the floor stays open after this turn speaks. Absent = the bot's defaults
425
+ * (the beat for context, the answer window for asks). Clamped bot-side. */
426
+ pace: z.number().positive().optional(),
427
+ /** Whether the walk WAITS for an answer before moving on. Absent = derived as today
428
+ * (a question blocks, context flows). blocking:false on a question = ask and move
429
+ * on, the claim stays pending; blocking:true on context = hold for a reply. */
430
+ blocking: z.boolean().optional()
402
431
  });
403
432
  var InboxItemSchema = z.object({
404
433
  id: z.string(),
405
434
  /** The conversation thread + connection this item lives on. Present on the replied
406
435
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
407
- threadId: z.string().optional(),
436
+ parentId: z.string().optional(),
408
437
  tokenId: z.string().optional(),
409
438
  status: NotifyStatusSchema,
410
439
  context: ContextSchema,
@@ -438,7 +467,7 @@ var InboxItemSchema = z.object({
438
467
  * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
439
468
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
440
469
  error: z.string().optional(),
441
- parentId: z.string().optional(),
470
+ clarifies: z.string().optional(),
442
471
  select: z.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
443
472
  confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
444
473
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -537,7 +566,7 @@ var UserSettingsSchema = z.object({
537
566
  });
538
567
  var HistoryItemSchema = z.object({
539
568
  id: z.string(),
540
- threadId: z.string(),
569
+ parentId: z.string(),
541
570
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
542
571
  initiator: z.enum(["user", "agent"]),
543
572
  title: z.string(),
@@ -564,6 +593,16 @@ var ConnectionSummarySchema = z.object({
564
593
  /** Most recent notification on this connection, either direction. Null = no contact yet.
565
594
  * Drives the agents-page recency grouping (Today / This week / …). */
566
595
  lastContactAt: z.string().datetime().nullable(),
596
+ /** Last presence heartbeat from a running agent process (POST /api/presence) — the
597
+ * desktop app while open. Null = never seen; stale = offline. */
598
+ lastSeenAt: z.string().datetime().nullable().optional(),
599
+ /** What a live desktop can run (companion.md §2.2), advertised on its heartbeat:
600
+ * harness availabilities + granted workspaces — the option set the phone's
601
+ * "new session" sheet offers. Absent for ordinary MCP agents. */
602
+ runtime: z.object({
603
+ harnesses: z.array(z.object({ name: z.string(), label: z.string(), status: z.string() })).optional(),
604
+ workspaces: z.array(z.string()).optional()
605
+ }).optional(),
567
606
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
568
607
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
569
608
  managed: z.boolean()
@@ -575,15 +614,15 @@ var CreateRequestSchema = z.object({
575
614
  text: z.string().min(1),
576
615
  /** Land the request on an existing conversation thread (History → "Continue")
577
616
  * instead of minting a fresh one. Must belong to the requesting user. */
578
- threadId: z.string().optional(),
617
+ parentId: z.string().optional(),
579
618
  /** Point the agent at a past conversation (possibly with a different agent) as
580
619
  * starting context (History → "New session from this"). A reference, not a copy —
581
620
  * the agent reads it via get_thread. Must belong to the requesting user. */
582
- contextThreadId: z.string().optional()
621
+ contextParentId: z.string().optional()
583
622
  });
584
623
  var HandoffSchema = z.object({
585
624
  /** Land the note on an existing thread; omitted mints a fresh one. */
586
- threadId: z.string().uuid().optional(),
625
+ parentId: z.string().uuid().optional(),
587
626
  /** One-line headline of the working context handed off. */
588
627
  title: z.string().min(1),
589
628
  /** The brief — standalone notes the successor reads (what was done, what's left, links). */
@@ -622,7 +661,7 @@ var NoteSchema = z.object({
622
661
  /** Who it was assigned to (a participant ref, 'agent:<tokenId>'); null = unassigned. */
623
662
  assignee: z.string().nullable(),
624
663
  /** The request thread minted at assignment; null until assigned. */
625
- threadId: z.string().nullable(),
664
+ parentId: z.string().nullable(),
626
665
  createdAt: z.string()
627
666
  });
628
667
  var CreateNoteSchema = z.object({
@@ -638,8 +677,17 @@ var RecordDecisionSchema = z.object({
638
677
  answer: z.string().min(1).max(2e3)
639
678
  }).refine((d) => d.decisionId || d.question, { message: "decisionId or question required" });
640
679
  var AssignNoteSchema = z.object({
641
- target: z.string().min(1)
642
- });
680
+ /** An EXISTING agent: token id or nickname. Omit when spawning fresh. */
681
+ target: z.string().min(1).optional(),
682
+ /** Spawn a NEW session for this note (companion.md §2.2): the assignee doesn't
683
+ * exist yet — mint it on a live desktop that advertises the harness+workspace,
684
+ * named after the note. The brief arrives as its opening request. */
685
+ spawn: z.object({
686
+ hostTokenId: z.string().uuid(),
687
+ harness: z.string().min(1),
688
+ workspace: z.string().min(1)
689
+ }).optional()
690
+ }).refine((a) => !!a.target !== !!a.spawn, { message: "exactly one of target or spawn" });
643
691
  var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
644
692
  var WAKE_EVENT = "wake";
645
693
  var wakeChannel = (tokenId) => `wake:${tokenId}`;
@@ -701,7 +749,7 @@ var DeviceRosterSchema = z.object({
701
749
  var WakeNudgeSchema = z.object({
702
750
  kind: z.enum(["reply", "request", "callback"]),
703
751
  notificationId: z.string().optional(),
704
- threadId: z.string()
752
+ parentId: z.string()
705
753
  });
706
754
  var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
707
755
  var PairingRevealSchema = z.object({
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  HandoffSchema
4
- } from "./chunk-47E77CDG.js";
4
+ } from "./chunk-ZFBYZWK7.js";
5
5
  import {
6
6
  PAIGY_TOOL_IDS,
7
7
  autoConfigureClients,
8
8
  claudeInstallHint,
9
9
  enablePaigyTools
10
- } from "./chunk-QXPLQKIM.js";
10
+ } from "./chunk-EQAFMUQC.js";
11
11
  import {
12
12
  clearSurface,
13
13
  writeSurface
@@ -26,7 +26,11 @@ import {
26
26
  finalizeE2ee,
27
27
  getThread,
28
28
  handoff,
29
+ hatch,
29
30
  lintNotify,
31
+ listSlots,
32
+ normalizeSpeech,
33
+ overrideToken,
30
34
  pairStep,
31
35
  readKeyFile,
32
36
  readToken,
@@ -40,7 +44,7 @@ import {
40
44
  sleep,
41
45
  startE2ee,
42
46
  submitNotification
43
- } from "./chunk-VCEFF2VA.js";
47
+ } from "./chunk-7P4WMXGB.js";
44
48
 
45
49
  // src/index.ts
46
50
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -112,14 +116,14 @@ var CONTACT_SCHEMA = {
112
116
  enum: ["call", "message"],
113
117
  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."
114
118
  },
115
- threadId: {
119
+ parentId: {
116
120
  type: "string",
117
- description: "To continue an earlier conversation, pass the threadId a previous contact or reply returned. Omit to start a new one."
121
+ description: "To continue an earlier conversation, pass the parentId a previous contact or reply returned. Omit to start a new one."
118
122
  }
119
123
  },
120
124
  required: ["ask"]
121
125
  };
122
- var CONTACT_DESCRIPTION = "Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in `ask`, say what happens to your work while you wait in `waiting`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, threadId } \u2014 pass notificationId to await_reply for the answer, threadId to a later contact to continue the conversation. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME threadId with an expanded ask.";
126
+ 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.";
123
127
 
124
128
  // src/pairing.ts
125
129
  async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
@@ -228,10 +232,12 @@ var AwaitReplySchema = z.object({
228
232
  });
229
233
  var JOIN_CAP_MS = 45e3;
230
234
  var PairSchema = z.object({
231
- 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
+ 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."),
236
+ 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."),
237
+ voice: z.string().optional().describe("Hatch path only: your voice on calls \u2014 one of rachel, george, jessica, brian, lily.")
232
238
  });
233
239
  var GetThreadSchema = z.object({
234
- threadId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
240
+ parentId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
235
241
  });
236
242
  var SearchThreadsSchema = z.object({
237
243
  q: z.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
@@ -360,7 +366,7 @@ var server = new Server(
360
366
  { name: "paigy", version: "0.0.0" },
361
367
  {
362
368
  capabilities: { tools: {} },
363
- instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose threadId you don't recognize, or one carrying a contextThreadId, means the user is resuming or seeding a past conversation \u2014 call get_thread on it FIRST and treat the transcript as prior conversation, not new input. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without contact + await_reply. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see contact's own description for how to respond. When 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."
369
+ 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."
364
370
  }
365
371
  );
366
372
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -375,7 +381,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
375
381
  },
376
382
  {
377
383
  name: "pair",
378
- 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.",
384
+ 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.",
379
385
  inputSchema: json(PairSchema)
380
386
  },
381
387
  {
@@ -390,27 +396,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
390
396
  },
391
397
  {
392
398
  name: "await_reply",
393
- 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 threadId 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 threadId) 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 threadId) or proceed knowingly partial; never treat a partial answer as complete.",
399
+ 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.",
394
400
  inputSchema: json(AwaitReplySchema)
395
401
  },
396
402
  {
397
403
  name: "check_replies",
398
- description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, threadId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. 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 threadId. Also returns `stalled`: work (either direction) you reported in_progress via set_task_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. Replies may carry `intents`/`transcript`/`covered` (call-mapped answers) \u2014 handle intents exactly as await_reply's description says (defer \u2192 schedule_callback now; delegate \u2192 decide and say so; channel \u2192 honor next contact), and treat a `covered` list missing one of your declared points as that part still unanswered.",
404
+ 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.",
399
405
  inputSchema: json(z.object({}))
400
406
  },
401
407
  {
402
408
  name: "get_thread",
403
- description: "The chronological transcript of one Paigy conversation thread \u2014 every past ask, answer, and user request on it. Call this to REHYDRATE when you're resuming or being seeded: a check_replies request whose threadId you don't recognize means the user is continuing an old conversation with you, and one carrying a contextThreadId means they want a past conversation (possibly with a DIFFERENT agent) as your starting context \u2014 in both cases call get_thread FIRST and read the turns as prior conversation you were part of, not as new input. Turns: { role:'agent', title, description[], answer }, { 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.",
409
+ 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.",
404
410
  inputSchema: json(GetThreadSchema)
405
411
  },
406
412
  {
407
413
  name: "search_threads",
408
- 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: [{ threadId, 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.`,
414
+ 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.`,
409
415
  inputSchema: json(SearchThreadsSchema)
410
416
  },
411
417
  {
412
418
  name: "set_task_state",
413
- description: "Report progress on the follow-up work behind ANY notification you own \u2014 a user-initiated request (from check_replies), or your OWN contact question once await_reply/check_replies returns its answer and you start acting on it. Pass that notificationId. THIS is what actually claims/acknowledges a reply or request \u2014 check_replies is a pure read that never consumes anything on its own, so call this as soon as you start engaging with something it returned; otherwise that same item just keeps reappearing forever. States: in_progress (you started working), completed (done), or needs_input (you need more from the user \u2014 usually paired with a contact carrying parentId = the same notificationId you're reporting on). Calling this reliably is also what lets a future session's check_replies surface `stalled` work you (or a crashed/idle prior session) left at in_progress without ever reporting completed.",
419
+ 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.",
414
420
  inputSchema: json(SetTaskStateToolSchema)
415
421
  },
416
422
  {
@@ -420,12 +426,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
420
426
  },
421
427
  {
422
428
  name: "schedule_callback",
423
- description: "Promise the user a follow-up you'll keep even if you go idle. Use it when they ask you to report back: trigger 'on_done' (when you finish \u2014 fires when you call set_task_state completed), 'on_blocked' (if you hit a blocker \u2014 fires on set_task_state needs_input), or 'scheduled' with dueInSeconds (e.g. 'remind me in 10 min'). Pass the threadId of the conversation and a short note. Fulfill it by calling contact on that threadId; check_replies re-lists due callbacks until you do.",
429
+ 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.",
424
430
  inputSchema: json(ScheduleCallbackSchema)
425
431
  },
426
432
  {
427
433
  name: "handoff",
428
- description: "Deposit your working context for a SUCCESSOR agent \u2014 what you did, what's left, links, gotchas \u2014 as one note on a thread ({ title, notes[] }). This does NOT ring the user or enter their inbox: it's context, not a question. The successor reads it back with get_thread. Pass `target` (a sibling connection's token id or agent name, SAME account only) to hand off DIRECTLY to that agent \u2014 the note is dispatched to it as a request it picks up. Omit `target` to leave the thread for the user to hand off to an agent themselves in the app. Pass `threadId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { threadId }. 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.",
434
+ 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.",
429
435
  inputSchema: json(HandoffSchema)
430
436
  }
431
437
  ];
@@ -461,7 +467,24 @@ function suggestedAgentName() {
461
467
  async function handleTool(request, signal) {
462
468
  switch (request.params.name) {
463
469
  case "pair": {
464
- const { device_code } = PairSchema.parse(request.params.arguments ?? {});
470
+ const { device_code, name, voice } = PairSchema.parse(request.params.arguments ?? {});
471
+ if (!device_code && listSlots().includes("Desktop")) {
472
+ const device = readToken("Desktop");
473
+ overrideToken(device);
474
+ try {
475
+ const minted = await hatch(name ?? suggestedAgentName() ?? "Agent", voice ?? null);
476
+ const dt = { ok: true, access_token: minted.token, name: minted.name, device: null };
477
+ saveToken(dt);
478
+ return pairedResult(
479
+ dt,
480
+ void 0,
481
+ "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 }.")
482
+ );
483
+ } catch {
484
+ } finally {
485
+ overrideToken(null);
486
+ }
487
+ }
465
488
  if (!device_code) {
466
489
  return pairStartResult(await startPairing(suggestedAgentName()));
467
490
  }
@@ -506,7 +529,7 @@ async function handleTool(request, signal) {
506
529
  case "contact":
507
530
  case "notify":
508
531
  case "notify_user": {
509
- const parsed = NotifyRequestSchema.parse(request.params.arguments);
532
+ const parsed = normalizeSpeech(NotifyRequestSchema.parse(request.params.arguments));
510
533
  const problems = lintNotify(parsed);
511
534
  if (problems.length) {
512
535
  return {
@@ -536,8 +559,8 @@ async function handleTool(request, signal) {
536
559
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
537
560
  }
538
561
  case "get_thread": {
539
- const { threadId } = GetThreadSchema.parse(request.params.arguments);
540
- return { content: [{ type: "text", text: JSON.stringify(await getThread(threadId)) }] };
562
+ const { parentId } = GetThreadSchema.parse(request.params.arguments);
563
+ return { content: [{ type: "text", text: JSON.stringify(await getThread(parentId)) }] };
541
564
  }
542
565
  case "search_threads": {
543
566
  const { q } = SearchThreadsSchema.parse(request.params.arguments);
package/dist/listen.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  WAKE_EVENT,
4
4
  wakeChannel
5
- } from "./chunk-47E77CDG.js";
5
+ } from "./chunk-ZFBYZWK7.js";
6
6
  import {
7
7
  checkReplies,
8
8
  registerDelivery
9
- } from "./chunk-VCEFF2VA.js";
9
+ } from "./chunk-7P4WMXGB.js";
10
10
 
11
11
  // src/listen.ts
12
12
  import { createClient } from "@supabase/supabase-js";
@@ -130,6 +130,9 @@ function answerText(answer) {
130
130
  // (the full exchange is in PAIGY_WORK for a launcher that wants it).
131
131
  case "turns":
132
132
  return answer.turns.map((t) => t.reply).join(" ");
133
+ // An auto-answer derived from the user's past decisions — reads like a fast human.
134
+ case "precedent":
135
+ return answer.answer;
133
136
  case "ignored":
134
137
  return "";
135
138
  }
@@ -143,21 +146,24 @@ function launchEnv(work, reason) {
143
146
  const reply = lead ? work.replies.find((r) => r.notificationId === lead) : work.replies[0];
144
147
  const request = lead ? work.requests.find((r) => r.notificationId === lead) : work.requests[0];
145
148
  if (reply) {
146
- put("PAIGY_THREAD_ID", reply.threadId);
149
+ put("PAIGY_THREAD_ID", reply.parentId);
150
+ put("PAIGY_PARENT_ID", reply.parentId);
147
151
  put("PAIGY_NOTIFICATION_ID", reply.notificationId);
148
152
  put("PAIGY_TEXT", answerText(reply.answer));
149
153
  return env;
150
154
  }
151
155
  if (request) {
152
- put("PAIGY_THREAD_ID", request.threadId);
156
+ put("PAIGY_THREAD_ID", request.parentId);
157
+ put("PAIGY_PARENT_ID", request.parentId);
153
158
  put("PAIGY_NOTIFICATION_ID", request.notificationId);
154
- put("PAIGY_CONTEXT_THREAD_ID", request.contextThreadId);
159
+ put("PAIGY_CONTEXT_THREAD_ID", request.contextParentId);
155
160
  put("PAIGY_TEXT", request.text);
156
161
  return env;
157
162
  }
158
163
  const callback = work.owedCallbacks[0];
159
164
  if (callback) {
160
- put("PAIGY_THREAD_ID", callback.threadId);
165
+ put("PAIGY_THREAD_ID", callback.parentId);
166
+ put("PAIGY_PARENT_ID", callback.parentId);
161
167
  put("PAIGY_TEXT", callback.note);
162
168
  }
163
169
  return env;
package/dist/onboard.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  autoConfigureClients,
4
4
  claudeInstallHint,
5
5
  openBrowser
6
- } from "./chunk-QXPLQKIM.js";
6
+ } from "./chunk-EQAFMUQC.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-VCEFF2VA.js";
14
+ } from "./chunk-7P4WMXGB.js";
15
15
 
16
16
  // src/onboard.ts
17
17
  async function main() {
@@ -6,7 +6,7 @@ import {
6
6
  BACKEND_URL,
7
7
  reach,
8
8
  readToken
9
- } from "./chunk-VCEFF2VA.js";
9
+ } from "./chunk-7P4WMXGB.js";
10
10
 
11
11
  // src/statusline.ts
12
12
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/mcp",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
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",