@paigy/mcp 0.40.17 → 0.40.20

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.
@@ -4,10 +4,10 @@ import {
4
4
  serverInstructions,
5
5
  sessionStartHook,
6
6
  withSessionStartHook
7
- } from "./chunk-MWHYYLCE.js";
7
+ } from "./chunk-RLN2B5IZ.js";
8
8
  import {
9
9
  agentName
10
- } from "./chunk-V5AQASR7.js";
10
+ } from "./chunk-6C6CO7H3.js";
11
11
 
12
12
  // src/toolset.ts
13
13
  var ONBOARD_DESCRIPTION = "Get this agent talking to Paigy \u2014 call it FIRST, before contact, 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.";
@@ -99,6 +99,55 @@ function withPaigyMcpToml(existing, agent) {
99
99
  ].join("\n");
100
100
  return existing + entry;
101
101
  }
102
+ function stalledHookCommand(agent) {
103
+ return `npx -y -p @paigy/mcp@latest paigy-stalled --agent ${agent}`;
104
+ }
105
+ function withPaigyStopHookToml(existing, agent) {
106
+ if (existing.includes("paigy-stalled")) return null;
107
+ const entry = [
108
+ "",
109
+ "[[hooks.Stop]]",
110
+ "",
111
+ "[[hooks.Stop.hooks]]",
112
+ 'type = "command"',
113
+ `command = "${stalledHookCommand(agent)}"`,
114
+ "timeout = 60",
115
+ // seconds, in Codex — a cold `npx` resolve is slow the first time
116
+ ""
117
+ ].join("\n");
118
+ return existing + entry;
119
+ }
120
+ function withPaigyStopHookJson(existing, agent) {
121
+ let config = {};
122
+ if (existing) {
123
+ try {
124
+ config = JSON.parse(existing);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+ const hooks = config.hooks ?? {};
130
+ const kept = (hooks.AfterAgent ?? []).map((group) => ({ ...group, hooks: (group.hooks ?? []).filter((hook) => hook.name !== "paigy-stalled") })).filter((group) => group.hooks.length > 0);
131
+ config.hooks = {
132
+ ...hooks,
133
+ AfterAgent: [
134
+ ...kept,
135
+ {
136
+ matcher: "*",
137
+ hooks: [
138
+ {
139
+ name: "paigy-stalled",
140
+ type: "command",
141
+ command: stalledHookCommand(agent),
142
+ timeout: 6e4
143
+ // milliseconds, in Gemini CLI
144
+ }
145
+ ]
146
+ }
147
+ ]
148
+ };
149
+ return JSON.stringify(config, null, 2);
150
+ }
102
151
  function autoConfigureClients(skip = agentName()) {
103
152
  const home = homedir();
104
153
  const registered = [];
@@ -118,6 +167,20 @@ function autoConfigureClients(skip = agentName()) {
118
167
  } catch {
119
168
  }
120
169
  }
170
+ for (const path of [
171
+ join(home, ".gemini", "settings.json"),
172
+ join(home, ".gemini", "antigravity-cli", "settings.json")
173
+ ]) {
174
+ try {
175
+ if (!existsSync(dirname(path))) continue;
176
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : null;
177
+ const updated = withPaigyStopHookJson(existing, "antigravity");
178
+ if (updated === null) continue;
179
+ writeFileSync(path, updated);
180
+ registered.push(`Paigy's turn-end reminder (${path})`);
181
+ } catch {
182
+ }
183
+ }
121
184
  }
122
185
  if (skip !== "codex") {
123
186
  const codexConfig = join(home, ".codex", "config.toml");
@@ -129,6 +192,12 @@ function autoConfigureClients(skip = agentName()) {
129
192
  writeFileSync(codexConfig, updated);
130
193
  registered.push(`OpenAI Codex (${codexConfig})`);
131
194
  }
195
+ const afterMcp = existsSync(codexConfig) ? readFileSync(codexConfig, "utf8") : "";
196
+ const withHook = withPaigyStopHookToml(afterMcp, "codex");
197
+ if (withHook !== null) {
198
+ writeFileSync(codexConfig, withHook);
199
+ registered.push(`Paigy's turn-end reminder (${codexConfig})`);
200
+ }
132
201
  }
133
202
  } catch {
134
203
  }
@@ -57,18 +57,18 @@ var AskInputSchema = z2.object({
57
57
  parentId: z2.string().uuid().optional().describe("The Goal this question is about \u2014 usually the one you are working on. The question goes onto that Goal and its answer comes back there. Omit it and Paigy places the question in the tree itself."),
58
58
  repo: z2.string().optional().describe("Optional repository context."),
59
59
  ask: z2.string().trim().min(1).max(1e4).describe(
60
- "ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 parts of one piece of work under one Goal, separate things as separate Goals \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; a call contact JOINS a call already happening, so several arrive as one call."
60
+ "ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 each question a Question on the one Goal the ask lands on, never a Goal of its own \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; ANY contact for a person already on a call JOINS that call, whatever channel you asked for, so several arrive as one call."
61
61
  ),
62
- options: z2.array(OptionInputSchema).min(2).max(6).optional()
62
+ options: z2.array(OptionInputSchema).min(1).max(6).optional()
63
63
  }).strict();
64
64
  var StartContactSchema = z2.object({
65
65
  asks: z2.array(AskInputSchema).min(1).describe("The questions to pose, one per object. An ask with a parentId is filed on that Goal as it is; only an ask naming no Goal is placed in the tree by Paigy."),
66
- waiting: z2.enum(["none", "hard"]).default("none"),
66
+ waiting: z2.enum(["none", "hard"]).default("none").describe("hard requests a call even when channel is notification; user permissions and ring cooldowns still apply."),
67
67
  channel: z2.enum(["notification", "call"]).default("notification")
68
68
  }).strict();
69
69
  var ContactSchema = z2.union([StartContactSchema, z2.object({ deliveryId: z2.string().uuid() }).strict()]);
70
70
  var CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
71
- var CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read \u2014 parts of one piece of work under one Goal, separate things as separate Goals. An ask with no options that is not blocking is a report: on a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so).";
71
+ var CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
72
72
  var CreateGoalSchema = z3.object({
73
73
  outcome: z3.string().trim().min(1).max(1e4),
74
74
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -106,16 +106,29 @@ var UpdateGoalSchema = z3.object({
106
106
  state: z3.enum(["active", "done", "cancelled"]).optional(),
107
107
  progress: z3.string().trim().min(1).max(1e4).optional(),
108
108
  reviewed: z3.literal(true).optional(),
109
- dueAt: z3.string().datetime({ offset: true }).nullable().optional()
109
+ dueAt: z3.string().datetime({ offset: true }).nullable().optional(),
110
+ /** WITHDRAW YOUR OWN QUESTION (#2777, owner 2026-09-30). The question's id as every read shows it
111
+ * (the conversation's `id`, eight characters, or the whole request Entry id), on THIS Goal, asked
112
+ * by you and still open. It is cancelled, not answered: its cards close and nothing rings for it. */
113
+ withdraw: z3.array(z3.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/)).min(1).max(10).optional()
110
114
  }).strict().refine((v) => Object.keys(v).length > 0),
111
115
  reason: z3.string().trim().min(1).max(2e3),
112
116
  operationId: z3.string().uuid().optional()
113
117
  }).strict();
114
118
  var UpdateGoalToolSchema = UpdateGoalSchema.omit({ operationId: true }).extend({ goalId: z3.string().uuid() }).strict();
115
119
  var ClaimGoalSchema = z3.object({ goalId: z3.string().uuid().optional() }).strict();
116
- var GetGoalSchema = z3.object({ goalId: z3.string().uuid() }).strict();
117
- var GET_GOAL_DESCRIPTION = "Read one Goal without claiming it: its outcome, state, revision, progress, blockers, the conversation on it (each question with its options and what was decided), and `next`, the one step to take. Foreign or sibling-owned Goals are not disclosed.";
118
- var UPDATE_GOAL_DESCRIPTION = `Update an owned Goal at an exact revision. State, ownership, dependencies, children, progress, title, and review acknowledgement are explicit; stale revisions are rejected. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
120
+ var GetGoalSchema = z3.object({
121
+ goalId: z3.string().uuid(),
122
+ /** Every entry in full. Without it the read carries the person's words, open questions and your
123
+ * newest entry, and counts what it left out (`apps/api/src/goal/collapse.ts`). */
124
+ history: z3.boolean().optional(),
125
+ /** WHEN A READ HAS CONFUSED YOU. Adds `diagnosis`: every reader that already answers a question
126
+ * about this work, each answer attributed to the reader that gave it, and every disagreement
127
+ * between two of them named. Off by default; `apps/api/src/goal/diagnose-design.md`. */
128
+ diagnose: z3.boolean().optional()
129
+ }).strict();
130
+ var GET_GOAL_DESCRIPTION = "Read one Goal without claiming it: its outcome, state, revision, progress, blockers, the conversation on it (each question with its options and what was decided), `next`, the one step to take, and `more`, where to read further. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
131
+ var UPDATE_GOAL_DESCRIPTION = `Update an owned Goal at an exact revision. State, ownership, dependencies, children, progress, title, and review acknowledgement are explicit; stale revisions are rejected. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
119
132
  var CLAIM_GOAL_DESCRIPTION = "Claim the oldest runnable or review-pending Goal you own, or pass goalId to claim that Goal. Another agent's Goal that has gone quiet for 3 days (check_replies lists them as stalledOthers) is taken over when you claim it, and becomes yours to finish or cancel. Returns the Goal as get_goal reads it, and creates or renews the execution lease.";
120
133
  var CHECK_REPLIES_DESCRIPTION = "What is waiting for you: your open Deliveries (a request the user started toward you, a handoff), one row each with its Goals, how many decisions are still open, and the newest words in brief; `assigned`, your Goals nobody has started yet, however they became yours (claim_goal({goalId}) starts one); and `review`, your Goals with something new on them \u2014 the user's answers and notes are Entries on the Goal, not Deliveries. A pure read with no arguments: nothing is consumed, acknowledged or claimed by reading it, so call it on startup, after a long wait, or whenever you want to know what is outstanding. To act on one, claim its Goal (claim_goal) or reread a Delivery in full with contact({deliveryId}). Once you have acted on what arrived, update_goal with reviewed: true clears the Goal from `review` and closes the Deliveries addressed to you on it.";
121
134
  var CheckRepliesSchema = z3.object({}).strict();
@@ -136,7 +149,7 @@ HOW TO ASK:
136
149
  1. One question per ask. Five questions are five objects in \`asks\`, in one contact, so each can be answered on its own; one question with five parts settles nothing until all five are answered.
137
150
  2. Name the Goal you are working on as the ask's \`parentId\`: the question goes onto that Goal and its answer comes back there. Without one, Paigy places it.
138
151
  3. \`waiting: hard\` only for a decision you are blocked on; \`waiting: none\` for a question you can keep working around.
139
- 4. Never hold the process open with while-loops. Let the current process finish or schedule a wakeup, and collect answers with \`check_replies\` or \`claim_goal\` on the next wake.`;
152
+ 4. Never hold the process open with while-loops. For an open Call with a pending decision, follow its \`next\` with another bounded \`contact({deliveryId})\` tool call; a returned wait window is not a completed conversation. Otherwise, yield only with a working listener or scheduled wakeup, and collect answers with \`check_replies\` or \`claim_goal\` on that wake.`;
140
153
  }
141
154
  function entryWords(entry) {
142
155
  const content = entry.content;
@@ -558,7 +571,13 @@ var InboxItemSchema = z4.object({
558
571
  * request Entry's author, as the same three facts the item's own `tokenId`/`name`/`voice`
559
572
  * carry for the call's one agent, so the phone draws it with the same seed. Absent when the
560
573
  * author is not an agent this account holds (unpaired since, or a person). */
561
- agent: z4.object({ tokenId: z4.string(), name: z4.string(), voice: VoiceKeySchema.optional() }).optional()
574
+ agent: z4.object({ tokenId: z4.string(), name: z4.string(), voice: VoiceKeySchema.optional() }).optional(),
575
+ /** ITS OPTIONS, WHEN THERE IS SOMETHING TO SEE (owner, 2026-09-25: "Yes, add it"): the options
576
+ * its need offers, exactly as its own card carries them, present only when one of them has a
577
+ * preview (`html` or `image`). The call screen opens them from the agenda row, so a preview is
578
+ * never re-sent as a second card to be seen mid-call. Words-only options are absent — the bot
579
+ * says those, and the list stays small (an `html` is up to 16 KB). */
580
+ options: z4.array(OptionSchema).optional()
562
581
  })).optional(),
563
582
  visuals: z4.array(VisualSchema).optional(),
564
583
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -738,18 +757,20 @@ var UserSettingsSchema = z4.object({
738
757
  * clobber guard as voiceMode: absent = leave unchanged on write. */
739
758
  broker: BrokerTuningSchema.optional()
740
759
  });
741
- var HistoryItemSchema = z4.object({
760
+ var HistoryWorkSchema = z4.object({
742
761
  id: z4.string(),
743
- /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
744
- initiator: z4.enum(["user", "agent"]),
745
762
  title: z4.string(),
746
- /** The agent on the other end (its name). */
747
- name: z4.string(),
748
- createdAt: z4.string(),
749
- /** When the agent fetched your request (user→agent only). */
750
- agentAckedAt: z4.string().nullable(),
751
- /** When you answered the agent's notification (agent→user only). */
752
- humanAckedAt: z4.string().nullable()
763
+ state: z4.enum(["done", "cancelled"]),
764
+ /** Who held it (`agent:<tokenId>` or `human:<userId>`). */
765
+ assignee: z4.string()
766
+ });
767
+ var HistoryEntrySchema = z4.union([
768
+ z4.object({ at: z4.string(), card: InboxItemSchema }),
769
+ z4.object({ at: z4.string(), work: HistoryWorkSchema })
770
+ ]);
771
+ var HistoryPageSchema = z4.object({
772
+ entries: z4.array(HistoryEntrySchema),
773
+ next: z4.string().nullable()
753
774
  });
754
775
  var ACTIVITY_LINES = 2;
755
776
  var ACTIVITY_LINE_MAX = 80;
@@ -933,6 +954,9 @@ var QueueItemSchema = z4.object({
933
954
  progressLine: z4.string().nullable().optional(),
934
955
  reviewPending: z4.boolean().default(false),
935
956
  dueAt: z4.string().nullable().default(null),
957
+ /** WHEN ITS OWNER SAID DONE WHILE CHILDREN WERE OPEN (#2704): its own work is finished and it closes
958
+ * with its last open child. Null otherwise; optional, so hand-built queues need not spell it. */
959
+ finishedAt: z4.string().nullable().optional(),
936
960
  /** The Goal this one was opened under; null at the root. */
937
961
  parentGoalId: z4.string().nullable().default(null),
938
962
  /** Goals opened under this one — only those the same list holds. */
@@ -941,8 +965,17 @@ var QueueItemSchema = z4.object({
941
965
  dependencyGoalIds: z4.array(z4.string()).default([]),
942
966
  /** True while any gate is on a Goal that is not done — the walk draws it dashed. */
943
967
  blocked: z4.boolean().default(false),
944
- /** Every decision need on it, open or settled — the page decides which to show. */
968
+ /** Its questions: every OPEN one, and at most ten settled, newest settled first
969
+ * (20260929133308) — the page decides which of them to show. NOT the whole set: `asked` and
970
+ * `answered` are, and a settled one's words are a line (280 characters), its body read when the
971
+ * question is opened. */
945
972
  questions: z4.array(QueueQuestionSchema).default([]),
973
+ /** HOW MANY QUESTIONS THIS WORK HAS ASKED, and how many are answered — the Goal's own totals,
974
+ * bounded at 100 server-side. A tally counted off `questions` is a wrong number that looks
975
+ * right once the cap bites (`walk/trees.ts` `tallyOf`). Optional, and defaulted from the array
976
+ * by the projection, so hand-built queues (fixtures, the demo) need not spell them. */
977
+ asked: z4.number().optional(),
978
+ answered: z4.number().optional(),
946
979
  /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
947
980
  * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
948
981
  * not spell an empty list. */
@@ -954,7 +987,19 @@ var QueueItemSchema = z4.object({
954
987
  /** When its owner last SAID something about it (`goals.last_progress_at`, written by every
955
988
  * `update_goal` that changes `progress`). `updatedAt` moves for reasons nobody chose — a
956
989
  * state recomputed, a review flag — so it cannot tell work in hand from work gone quiet. */
957
- lastProgressAt: z4.string().nullable().optional()
990
+ lastProgressAt: z4.string().nullable().optional(),
991
+ /** THE GOAL'S NEWEST WORD, FROM EITHER SIDE (owner, 2026-09-27): the newest Entry on it, of any
992
+ * kind — what the person added ("Add to this"), their reply, the agent's ask or its progress
993
+ * note. A progress note is an Entry, so this is already the newer of the two: the person's note
994
+ * shows the moment it is written, and the agent's reply or next note replaces it by being newer.
995
+ * `said` is bounded to 280 characters server-side (a line, not the conversation). Null when the
996
+ * Goal carries no readable Entry; optional, so hand-built queues need not spell it. */
997
+ latest: z4.object({
998
+ from: z4.enum(["person", "agent"]),
999
+ said: z4.string(),
1000
+ at: z4.string(),
1001
+ entryId: z4.string()
1002
+ }).nullable().optional()
958
1003
  });
959
1004
  var COLD_AFTER_MS = 3 * 24 * 60 * 60 * 1e3;
960
1005
  var NoteSourceSchema = z4.enum(["app", "call"]);
@@ -1065,6 +1110,19 @@ var DeliveryConfigSchema = z4.object({
1065
1110
  * carries credentials; only a `poll` registration can come back with null here. */
1066
1111
  realtime: z4.object({ url: z4.string(), anonKey: z4.string() }).nullable()
1067
1112
  });
1113
+ var HostDecisionSchema = z4.object({
1114
+ /** The agent's token id: the row's `recipient`. */
1115
+ agent: z4.string().uuid(),
1116
+ decision: z4.enum(["stood_back", "took_over"]),
1117
+ /** The work it was about: the Goal `claim_goal` would hand that agent next. */
1118
+ goalId: z4.string().uuid().nullable().optional(),
1119
+ /** When the server last heard from the agent, as the host read it: the presence it stood back for. */
1120
+ seenAt: z4.string().datetime().nullable().optional(),
1121
+ /** When that work last moved (`claimable.since` on `check_replies`), the fact the bound is judged on. */
1122
+ since: z4.string().datetime().nullable().optional(),
1123
+ /** What the host said, in its log's own words: why it stood back, or what the take-over did. */
1124
+ said: z4.string().max(300).optional()
1125
+ });
1068
1126
  var WakeNudgeSchema = z4.object({
1069
1127
  kind: z4.enum(["reply", "request", "callback"]),
1070
1128
  notificationId: z4.string().optional(),
@@ -1109,6 +1167,9 @@ var DeviceTokenSchema = z4.object({
1109
1167
  * landed in the FIRST granted workspace and the agent rediscovered its own repo from
1110
1168
  * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
1111
1169
  workspace: z4.string().nullable().optional(),
1170
+ /** Local host recovery must preserve the launch's runtime and Paigy identity. */
1171
+ harness: z4.enum(["claude", "codex", "agy"]).optional(),
1172
+ session_id: z4.string().uuid().optional(),
1112
1173
  uik_pub: z4.string().nullable().optional()
1113
1174
  });
1114
1175
  var SupportRequestSchema = z4.object({
@@ -1130,6 +1191,104 @@ var NotificationFeedbackKindSchema = z4.enum([
1130
1191
  "other"
1131
1192
  // anything else — the note carries it.
1132
1193
  ]);
1194
+ var SlimOptionSchema = OptionSchema.omit({ html: true });
1195
+ var QuestionRowSchema = z4.object({
1196
+ /** The card's id (`deliveryId:needId`, or `deliveryId:entryId` for an update), as the inbox mints it. */
1197
+ id: z4.string(),
1198
+ deliveryId: z4.string(),
1199
+ entryId: z4.string(),
1200
+ /** The decision it waits on; null for an update, which asks nothing. */
1201
+ needId: z4.string().nullable(),
1202
+ goalIds: z4.array(z4.string()),
1203
+ /** The name of the work it is about, when the read could word it. */
1204
+ goalTitle: z4.string().optional(),
1205
+ tokenId: z4.string().optional(),
1206
+ name: z4.string(),
1207
+ title: z4.string(),
1208
+ body: z4.string(),
1209
+ select: z4.enum(["one", "many", "rank", "confirm", "text"]),
1210
+ options: z4.array(SlimOptionSchema),
1211
+ hasPreview: z4.boolean(),
1212
+ blocking: z4.boolean(),
1213
+ askedAt: z4.string().datetime(),
1214
+ ring: InboxItemSchema.shape.ring,
1215
+ onCall: z4.literal(true).optional(),
1216
+ sealed: z4.boolean()
1217
+ });
1218
+ var WorkStateSchema = z4.enum(["ready", "active", "waiting", "done", "cancelled"]);
1219
+ var WorkRowSchema = z4.object({
1220
+ id: z4.string(),
1221
+ parentId: z4.string().nullable(),
1222
+ title: z4.string(),
1223
+ /** Straight off the Goal. */
1224
+ state: WorkStateSchema,
1225
+ owner: z4.string().nullable(),
1226
+ revision: z4.number().int(),
1227
+ /** Open questions on it, counted to 100. */
1228
+ waiting: z4.number().int(),
1229
+ /** Held by a gate on work that is not done. */
1230
+ blocked: z4.boolean(),
1231
+ lastProgressAt: z4.string().datetime().nullable(),
1232
+ /** The line written for its newest progress note, else that note's first words. */
1233
+ line: z4.string().nullable(),
1234
+ /** Work directly under it, counted to 100; the list carries up to 12 of them. */
1235
+ children: z4.number().int(),
1236
+ createdAt: z4.string().datetime(),
1237
+ updatedAt: z4.string().datetime(),
1238
+ /** When anything at or under it last moved — the order the list is in. */
1239
+ activeAt: z4.string().datetime(),
1240
+ /** A sealed outcome has no title here; the work's page opens it. */
1241
+ sealed: z4.boolean()
1242
+ });
1243
+ var ComputerRowSchema = ConnectionSummarySchema.omit({ activity: true });
1244
+ var AgentRowSchema = ComputerRowSchema.extend({
1245
+ /** Open questions it is asking the person, over every open card; null when that read failed. */
1246
+ asking: z4.number().int().nullable(),
1247
+ oldestAskAt: z4.string().datetime().nullable(),
1248
+ /** Up to three of the live Goals it holds, oldest first (the order it picks them up), and how
1249
+ * many in all among the account's 200 most recently active agent-held live Goals
1250
+ * (`agent_holds`); null when that read failed. */
1251
+ holds: z4.array(z4.object({ id: z4.string(), title: z4.string() })).nullable(),
1252
+ held: z4.number().int().nullable(),
1253
+ /** The earliest instant any Goal it holds went quiet, by the one rule (`coldSince`); null
1254
+ * while none has, or when that read failed. */
1255
+ cold: z4.string().datetime().nullable(),
1256
+ /** The newest line of its working log, and when the harness saw it. */
1257
+ line: z4.string().nullable(),
1258
+ lineAt: z4.string().datetime().nullable()
1259
+ });
1260
+ var SnapshotSchema = z4.object({
1261
+ /** The API's clock, taken before the first read: what a later delta will start from. */
1262
+ at: z4.string().datetime(),
1263
+ questions: z4.object({
1264
+ /** The newest 30 open cards, questions before updates. */
1265
+ items: z4.array(QuestionRowSchema),
1266
+ /** Every open question, and apart from them every update, and what was put off. */
1267
+ total: z4.number().int(),
1268
+ updates: z4.number().int(),
1269
+ putOff: z4.number().int()
1270
+ }).nullable(),
1271
+ agents: z4.object({
1272
+ /** Up to 60, most recently seen first. */
1273
+ items: z4.array(AgentRowSchema),
1274
+ more: z4.boolean()
1275
+ }).nullable(),
1276
+ work: z4.object({
1277
+ /** The 60 most recently active roots, each followed by up to 12 children; 240 rows at most. */
1278
+ items: z4.array(WorkRowSchema),
1279
+ /** How much work is behind each of the Work tab's four filters, each counted to 100, read with
1280
+ * the rows. `work_list` (20260928023533) owns the predicates: Live is `ready`, `active` or
1281
+ * `waiting`; Waiting on you is live work with an open question or an unmet gate; Not started
1282
+ * is `ready`; Done is `done` or `cancelled`. */
1283
+ counts: z4.object({ live: z4.number().int(), waiting: z4.number().int(), notStarted: z4.number().int(), done: z4.number().int() })
1284
+ }).nullable(),
1285
+ you: z4.object({
1286
+ settings: UserSettingsSchema,
1287
+ callable: z4.boolean(),
1288
+ /** Up to 20 paired computers; null when the roster read failed. */
1289
+ computers: z4.array(ComputerRowSchema).nullable()
1290
+ }).nullable()
1291
+ });
1133
1292
 
1134
1293
  // src/listening.ts
1135
1294
  import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -1,10 +1,12 @@
1
1
  import {
2
+ NameTakenError,
2
3
  isNetworkError,
3
4
  pollToken,
4
5
  requestCode,
5
6
  saveToken,
7
+ setIdentity,
6
8
  sleep
7
- } from "./chunk-V5AQASR7.js";
9
+ } from "./chunk-6C6CO7H3.js";
8
10
 
9
11
  // src/identity.ts
10
12
  var CLIENT_LABELS = {
@@ -25,6 +27,21 @@ function suggestedAgentName(clientName) {
25
27
  if (!clientName) return void 0;
26
28
  return CLIENT_LABELS[clientName.toLowerCase()] ?? clientName.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
27
29
  }
30
+ async function identify(who, ask) {
31
+ let renamed = false;
32
+ if (ask.name || ask.voice) {
33
+ try {
34
+ who = await setIdentity({ ...ask.name ? { name: ask.name } : {}, ...ask.voice ? { voice: ask.voice } : {} });
35
+ renamed = true;
36
+ } catch (e) {
37
+ const message2 = e instanceof NameTakenError ? e.message : `The rename did not go through (${e.message}); nothing changed. Call onboard again to retry, or carry on.`;
38
+ return { name: who.name, voice: who.voice, message: message2 };
39
+ }
40
+ }
41
+ const voice = who.voice ? ` (voice: ${who.voice})` : "";
42
+ const message = !who.name ? [renamed && who.voice ? `Your voice is ${who.voice} now.` : "", who.unnamed ?? "Set up, with no name yet."].filter(Boolean).join(" ") : renamed ? `Renamed \u2014 this agent is "${who.name}"${voice} now.` : `Already set up as "${who.name}"${voice}. Ask the user if they want to rename it or unpair; otherwise carry on.`;
43
+ return { name: who.name, voice: who.voice, message };
44
+ }
28
45
 
29
46
  // src/pairing.ts
30
47
  async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
@@ -102,6 +119,7 @@ function clearPairing(deviceCode) {
102
119
 
103
120
  export {
104
121
  suggestedAgentName,
122
+ identify,
105
123
  resolvePairing,
106
124
  startPairing,
107
125
  joinBackgroundPair,
@@ -6,8 +6,10 @@ import {
6
6
  BACKEND_URL,
7
7
  KEY_PATH,
8
8
  NETWORK_MSG,
9
+ NameTakenError,
9
10
  NotifyRequestSchema,
10
11
  PROTO,
12
+ RevokedError,
11
13
  TOKEN_PATH,
12
14
  UnpairedError,
13
15
  acceptTriage,
@@ -35,6 +37,7 @@ import {
35
37
  reach,
36
38
  reachAs,
37
39
  readToken,
40
+ recordDecision,
38
41
  registerDelivery,
39
42
  repoFromRemote,
40
43
  requestCode,
@@ -52,7 +55,7 @@ import {
52
55
  updateGoal,
53
56
  updateSlot,
54
57
  whoAmI
55
- } from "./chunk-V5AQASR7.js";
58
+ } from "./chunk-6C6CO7H3.js";
56
59
  export {
57
60
  AGENT_TOOLS,
58
61
  AGENT_TOOL_NAMES,
@@ -61,8 +64,10 @@ export {
61
64
  BACKEND_URL,
62
65
  KEY_PATH,
63
66
  NETWORK_MSG,
67
+ NameTakenError,
64
68
  NotifyRequestSchema,
65
69
  PROTO,
70
+ RevokedError,
66
71
  TOKEN_PATH,
67
72
  UnpairedError,
68
73
  acceptTriage,
@@ -90,6 +95,7 @@ export {
90
95
  reach,
91
96
  reachAs,
92
97
  readToken,
98
+ recordDecision,
93
99
  registerDelivery,
94
100
  repoFromRemote,
95
101
  requestCode,
package/dist/enable.js CHANGED
@@ -3,9 +3,9 @@ import {
3
3
  PAIGY_TOOL_IDS,
4
4
  enablePaigyTools,
5
5
  installSessionListening
6
- } from "./chunk-7VLZIOLF.js";
7
- import "./chunk-MWHYYLCE.js";
8
- import "./chunk-V5AQASR7.js";
6
+ } from "./chunk-6MEUSSRH.js";
7
+ import "./chunk-RLN2B5IZ.js";
8
+ import "./chunk-6C6CO7H3.js";
9
9
 
10
10
  // src/enable.ts
11
11
  function main() {
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  clearPairing,
4
+ identify,
4
5
  joinBackgroundPair,
5
6
  resolvePairing,
6
7
  startPairing,
7
8
  suggestedAgentName
8
- } from "./chunk-UIZLBPRG.js";
9
+ } from "./chunk-YNKTKLQD.js";
9
10
  import {
10
11
  ENABLE_COMMAND,
11
12
  OnboardSchema,
@@ -14,11 +15,11 @@ import {
14
15
  SERVER_INSTRUCTIONS,
15
16
  TOOLS,
16
17
  paigyToolsAllowlisted
17
- } from "./chunk-7VLZIOLF.js";
18
+ } from "./chunk-6MEUSSRH.js";
18
19
  import {
19
20
  decideListen,
20
21
  listenerAlive
21
- } from "./chunk-MWHYYLCE.js";
22
+ } from "./chunk-RLN2B5IZ.js";
22
23
  import {
23
24
  clearSurface,
24
25
  writeSurface
@@ -36,10 +37,9 @@ import {
36
37
  runTool,
37
38
  saveToken,
38
39
  sessionId,
39
- setIdentity,
40
40
  slotName,
41
41
  whoAmI
42
- } from "./chunk-V5AQASR7.js";
42
+ } from "./chunk-6C6CO7H3.js";
43
43
 
44
44
  // src/index.ts
45
45
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -177,7 +177,7 @@ async function hatchUnderDevice(name, voice) {
177
177
  const minted = await hatch(name ?? suggestedAgentName2() ?? "Agent", voice ?? null);
178
178
  const dt = { ok: true, access_token: minted.token, name: minted.name, device: null };
179
179
  saveToken(dt);
180
- return dt;
180
+ return minted.message ? { ...dt, message: minted.message } : dt;
181
181
  } catch (e) {
182
182
  return { why: "credential-rejected", detail: String(e?.message ?? e).slice(0, 200) };
183
183
  } finally {
@@ -191,7 +191,7 @@ async function runPair(device_code, name, voice, note) {
191
191
  if ("ok" in dt) return pairedResult(
192
192
  dt,
193
193
  void 0,
194
- "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 }.`)
194
+ "Hatched instantly under this device's credential \u2014 no code needed. " + (dt.message ? `${dt.message} Set it with onboard({ name }).` : 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 }.`)
195
195
  );
196
196
  miss = dt;
197
197
  }
@@ -246,24 +246,16 @@ async function handleTool(request, signal) {
246
246
  const slot = agentName();
247
247
  const secret = readToken();
248
248
  if (secret) {
249
- let who = await whoAmI().catch(() => null);
249
+ const who = await whoAmI().catch(() => null);
250
250
  if (who) {
251
- let renamed = false;
252
- if (name || voice) {
253
- try {
254
- who = await setIdentity({ ...name ? { name } : {}, ...voice ? { voice } : {} });
255
- renamed = true;
256
- } catch {
257
- }
258
- }
251
+ const me = await identify(who, { ...name ? { name } : {}, ...voice ? { voice } : {} });
259
252
  const allowlisted = paigyToolsAllowlisted(scope ?? "user");
260
- const display = who?.name ?? slotName(slot) ?? slot;
261
253
  return {
262
254
  content: [{ type: "text", text: JSON.stringify({
263
255
  status: "ready",
264
256
  slot,
265
- name: display,
266
- voice: who?.voice ?? null,
257
+ name: me.name,
258
+ voice: me.voice,
267
259
  tools_allowlisted: allowlisted,
268
260
  ...allowlisted ? {} : {
269
261
  enable_tool_ids: PAIGY_TOOL_IDS,
@@ -277,7 +269,7 @@ async function handleTool(request, signal) {
277
269
  rename: "Call onboard again with { name } (and optionally { voice }) to change it.",
278
270
  unpair: "Call the `unpair` tool to disconnect this agent from the account."
279
271
  },
280
- message: renamed ? `Renamed \u2014 this agent is "${display}" now.` : `Already set up as "${display}". Ask the user if they want to rename it or unpair; otherwise carry on.`
272
+ message: me.message
281
273
  }) }]
282
274
  };
283
275
  }
package/dist/listen.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  entryWords,
4
4
  removeListenMark,
5
5
  writeListenMark
6
- } from "./chunk-MWHYYLCE.js";
6
+ } from "./chunk-RLN2B5IZ.js";
7
7
  import {
8
8
  agentName,
9
9
  checkReplies,
@@ -11,7 +11,7 @@ import {
11
11
  getGoal,
12
12
  slotName,
13
13
  subscribeWake
14
- } from "./chunk-V5AQASR7.js";
14
+ } from "./chunk-6C6CO7H3.js";
15
15
 
16
16
  // src/listen.ts
17
17
  import { spawn } from "child_process";
@@ -124,6 +124,7 @@ var { brief: BRIEF, release: RELEASE } = listenOptions(process.argv);
124
124
  var line = (text) => void process.stdout.write(text + "\n");
125
125
  var emit = (obj) => line(JSON.stringify(obj));
126
126
  var listeningAs = () => slotName(agentName()) ?? agentName();
127
+ var SWEEP_EVERY_MS = Number(process.env.PAIGY_SWEEP_MS) || 9e4;
127
128
  function saidIn(delivery) {
128
129
  return ("entries" in delivery ? delivery.entries : []).filter((entry) => entry.kind === "contribution").map((entry) => entryWords(entry)).filter(Boolean).join("\n");
129
130
  }
@@ -192,7 +193,10 @@ async function main() {
192
193
  process.on("exit", () => removeListenMark(slot));
193
194
  if (!BRIEF) emit({ type: "listening", channel: sub.channel });
194
195
  await sweep("boot");
196
+ const heartbeat = setInterval(() => void sweep("timer"), SWEEP_EVERY_MS);
197
+ heartbeat.unref?.();
195
198
  const shutdown = async () => {
199
+ clearInterval(heartbeat);
196
200
  removeListenMark(slot);
197
201
  await sub.close();
198
202
  process.exit(0);
@@ -224,6 +228,7 @@ if (isEntry()) {
224
228
  }
225
229
  }
226
230
  export {
231
+ SWEEP_EVERY_MS,
227
232
  brief,
228
233
  catchUpReason,
229
234
  launchEnv,