@paigy/mcp 0.40.14 → 0.40.15

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
@@ -215,6 +215,20 @@ script and branch on `$PAIGY_EVENT` there:
215
215
  codex exec "Paigy goal $PAIGY_GOAL_ID. claim_goal it first. The user said: $PAIGY_TEXT. Reply with contact when done."
216
216
  ```
217
217
 
218
+ ## Stalled work (Claude Code stop hook)
219
+
220
+ An agent that goes quiet leaves its Goals behind. Three things hand it that work back, all
221
+ reading one rule (no progress for 3 days, `COLD_AFTER_MS`): `check_replies` lists it under
222
+ `stalled`, `paigy-listen` carries it in `PAIGY_WORK` (and launches for it on boot), and this
223
+ hook holds a Claude Code session's stop once a day to list it:
224
+
225
+ ```json
226
+ { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "npx -y -p @paigy/mcp paigy-stalled" }] }] } }
227
+ ```
228
+
229
+ It is silent when nothing is stalled, when the stop is already being continued by a hook, and
230
+ after it has reminded that session once today.
231
+
218
232
  ## Statusline (Claude Code)
219
233
 
220
234
  `paigy-statusline` prints which Paigy identity this Claude Code session is, for the
@@ -4,10 +4,10 @@ import {
4
4
  serverInstructions,
5
5
  sessionStartHook,
6
6
  withSessionStartHook
7
- } from "./chunk-NOZRU5CD.js";
7
+ } from "./chunk-SE2JCKDI.js";
8
8
  import {
9
9
  agentName
10
- } from "./chunk-R2GTL7N3.js";
10
+ } from "./chunk-PHTQITTC.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.";
@@ -79,6 +79,9 @@ function isNetworkError(e) {
79
79
  async function reach(url, init) {
80
80
  return send(url, init, sessionId());
81
81
  }
82
+ function reachAs(session) {
83
+ return (url, init) => send(url, init, session);
84
+ }
82
85
  async function send(url, init, session) {
83
86
  try {
84
87
  const headers = {
@@ -1319,6 +1322,174 @@ var OptionSchema = z.object({
1319
1322
  var OptionInputSchema = OptionSchema.omit({ id: true }).extend({
1320
1323
  label: z.string().trim().min(1).max(1e3)
1321
1324
  }).strict();
1325
+ function tzOffsetMinutes(tz, atMs) {
1326
+ try {
1327
+ const dtf = new Intl.DateTimeFormat("en-US", {
1328
+ timeZone: tz,
1329
+ hour12: false,
1330
+ year: "numeric",
1331
+ month: "2-digit",
1332
+ day: "2-digit",
1333
+ hour: "2-digit",
1334
+ minute: "2-digit",
1335
+ second: "2-digit"
1336
+ });
1337
+ const p = {};
1338
+ for (const { type, value } of dtf.formatToParts(new Date(atMs))) p[type] = value;
1339
+ const asUtc = Date.UTC(
1340
+ Number(p["year"]),
1341
+ Number(p["month"]) - 1,
1342
+ Number(p["day"]),
1343
+ Number(p["hour"]) === 24 ? 0 : Number(p["hour"]),
1344
+ Number(p["minute"]),
1345
+ Number(p["second"])
1346
+ );
1347
+ return Math.round((asUtc - atMs) / 6e4);
1348
+ } catch {
1349
+ return null;
1350
+ }
1351
+ }
1352
+ function instantAt(tz, local) {
1353
+ const naive = Date.UTC(local.y, local.mo - 1, local.d, local.h, local.mi, 0);
1354
+ const first = tzOffsetMinutes(tz, naive);
1355
+ if (first === null) return null;
1356
+ const guess = naive - first * 6e4;
1357
+ const second = tzOffsetMinutes(tz, guess);
1358
+ return second === null ? guess : naive - second * 6e4;
1359
+ }
1360
+ function localDate(tz, atMs) {
1361
+ const off = tzOffsetMinutes(tz, atMs);
1362
+ if (off === null) return null;
1363
+ const shifted = new Date(atMs + off * 6e4);
1364
+ return {
1365
+ y: shifted.getUTCFullYear(),
1366
+ mo: shifted.getUTCMonth() + 1,
1367
+ d: shifted.getUTCDate(),
1368
+ h: shifted.getUTCHours(),
1369
+ mi: shifted.getUTCMinutes()
1370
+ };
1371
+ }
1372
+ var PARTS = {
1373
+ earliest: { morning: 8, afternoon: 13, evening: 18, night: 20, noon: 12, midday: 12, lunch: 13 },
1374
+ ring: { morning: 9, afternoon: 13, evening: 18, night: 20, noon: 12, midday: 12, lunch: 13 }
1375
+ };
1376
+ var END_OF_DAY = 17;
1377
+ var VAGUE = /\b(later|in a bit|in a while)\b/i;
1378
+ var TODAY_ONLY = /\b(tonight|this evening)\b/i;
1379
+ var RELATIVE = /\bin\s+(a\s+couple\s+of|a\s+few|a|an|one|two|three|four|five|six|seven|eight|nine|ten|fifteen|twenty|thirty|forty|forty[- ]five|half\s+an|\d{1,3})\s*(minute|minutes|min|mins|hour|hours|hr|hrs|day|days|week|weeks)\b/i;
1380
+ var WORD_NUM = {
1381
+ a: 1,
1382
+ an: 1,
1383
+ one: 1,
1384
+ two: 2,
1385
+ three: 3,
1386
+ four: 4,
1387
+ five: 5,
1388
+ six: 6,
1389
+ seven: 7,
1390
+ eight: 8,
1391
+ nine: 9,
1392
+ ten: 10,
1393
+ fifteen: 15,
1394
+ twenty: 20,
1395
+ thirty: 30,
1396
+ forty: 40,
1397
+ "forty-five": 45,
1398
+ "forty five": 45,
1399
+ // Vague-but-real quantities the SDK's parser read and this one did not (#1367). "A few"
1400
+ // is deliberately small and "a couple" is exactly two — a caller who says either has
1401
+ // named a rough size, and rounding it away to null loses the request entirely.
1402
+ "a couple of": 2,
1403
+ "a few": 3
1404
+ };
1405
+ var CLOCK = /\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(a\.?m\.?|p\.?m\.?|o'?clock)?\b/i;
1406
+ var SPOKEN_HOUR = {
1407
+ one: 1,
1408
+ two: 2,
1409
+ three: 3,
1410
+ four: 4,
1411
+ five: 5,
1412
+ six: 6,
1413
+ seven: 7,
1414
+ eight: 8,
1415
+ nine: 9,
1416
+ ten: 10,
1417
+ eleven: 11,
1418
+ twelve: 12
1419
+ };
1420
+ function parseWhen(said2, tz, nowMs, read = "earliest") {
1421
+ const text = said2.trim();
1422
+ if (!text) return null;
1423
+ const rel = RELATIVE.exec(text);
1424
+ if (rel) {
1425
+ const rawN = rel[1].toLowerCase();
1426
+ const half = /half/.test(rawN);
1427
+ const n = half ? 0.5 : WORD_NUM[rawN] ?? Number(rawN);
1428
+ if (Number.isFinite(n) && n > 0) {
1429
+ const unit = rel[2].toLowerCase();
1430
+ const ms = unit.startsWith("min") ? n * 6e4 : unit.startsWith("h") ? n * 36e5 : unit.startsWith("d") ? n * 864e5 : n * 7 * 864e5;
1431
+ return new Date(nowMs + ms).toISOString();
1432
+ }
1433
+ }
1434
+ const today = localDate(tz, nowMs);
1435
+ if (!today) return null;
1436
+ let dayShift = /\btomorrow\b/i.test(text) ? 1 : 0;
1437
+ if (/\bnext week\b/i.test(text)) dayShift = 7;
1438
+ let hour = null;
1439
+ let minute = 0;
1440
+ const spoken = new RegExp(`\\bat\\s+(${Object.keys(SPOKEN_HOUR).join("|")})\\b`, "i").exec(text);
1441
+ const clock2 = CLOCK.exec(text);
1442
+ if (spoken) {
1443
+ hour = SPOKEN_HOUR[spoken[1].toLowerCase()];
1444
+ } else if (clock2 && (clock2[3] || /\bat\s/i.test(clock2[0]))) {
1445
+ hour = Number(clock2[1]);
1446
+ minute = clock2[2] ? Number(clock2[2]) : 0;
1447
+ if (hour > 23 || minute > 59) return null;
1448
+ }
1449
+ if (hour !== null) {
1450
+ const mer = clock2?.[3]?.toLowerCase().replace(/\./g, "") ?? "";
1451
+ const pmWord = /\b(p\.?m\.?|afternoon|evening|tonight|at night)\b/i.test(text);
1452
+ const amWord = /\b(a\.?m\.?|morning)\b/i.test(text);
1453
+ if (mer.startsWith("p") || pmWord && !amWord) {
1454
+ if (hour < 12) hour += 12;
1455
+ } else if (mer.startsWith("a") || amWord) {
1456
+ if (hour === 12) hour = 0;
1457
+ } else if (hour < 12 && dayShift === 0 && hour <= today.h) hour += 12;
1458
+ } else {
1459
+ const parts = PARTS[read];
1460
+ const part = Object.keys(parts).find((k) => new RegExp(`\\b${k}\\b`, "i").test(text));
1461
+ if (/\btonight\b/i.test(text)) hour = parts["night"];
1462
+ else if (part) hour = parts[part];
1463
+ else if (dayShift > 0) hour = 9;
1464
+ else if (/\bend of (the )?day\b|\beod\b/i.test(text)) hour = END_OF_DAY;
1465
+ else if (read === "ring" && VAGUE.test(text)) return new Date(nowMs + 30 * 6e4).toISOString();
1466
+ else return null;
1467
+ }
1468
+ const at2 = instantAt(tz, {
1469
+ y: today.y,
1470
+ mo: today.mo,
1471
+ d: today.d + dayShift,
1472
+ h: hour,
1473
+ mi: minute
1474
+ });
1475
+ if (at2 === null) return null;
1476
+ if (at2 <= nowMs && dayShift === 0 && TODAY_ONLY.test(text)) {
1477
+ return new Date(nowMs + 36e5).toISOString();
1478
+ }
1479
+ const rolled = at2 <= nowMs && dayShift === 0 ? instantAt(tz, {
1480
+ y: today.y,
1481
+ mo: today.mo,
1482
+ d: today.d + 1,
1483
+ h: hour,
1484
+ mi: minute
1485
+ }) : at2;
1486
+ return new Date(rolled ?? at2).toISOString();
1487
+ }
1488
+ function parseDelay(said2, tz, nowMs, read = "earliest") {
1489
+ const at2 = parseWhen(said2, tz, nowMs, read);
1490
+ if (!at2) return null;
1491
+ return Math.max(0, Math.round((Date.parse(at2) - nowMs) / 1e3));
1492
+ }
1322
1493
  var NIGHT = { from: 23, to: 7 };
1323
1494
  function draft2020(node) {
1324
1495
  if (Array.isArray(node)) return node.map(draft2020);
@@ -1354,7 +1525,7 @@ var AskInputSchema = z2.object({
1354
1525
  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."),
1355
1526
  repo: z2.string().optional().describe("Optional repository context."),
1356
1527
  ask: z2.string().trim().min(1).max(1e4).describe(
1357
- "The question, and only what is needed to answer it. News, progress and findings are their own contact \u2014 a call contact JOINS a call already happening, so several arrive as one call. Do not bundle: a DecisionNeed is settled only by an answer in the shape this ask declares, so someone who answers the part that interested them settles nothing and is asked again."
1528
+ "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."
1358
1529
  ),
1359
1530
  options: z2.array(OptionInputSchema).min(2).max(6).optional()
1360
1531
  }).strict();
@@ -1365,7 +1536,7 @@ var StartContactSchema = z2.object({
1365
1536
  }).strict();
1366
1537
  var ContactSchema = z2.union([StartContactSchema, z2.object({ deliveryId: z2.string().uuid() }).strict()]);
1367
1538
  var CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
1368
- 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}. Never bundle multiple questions into a single 'ask' string; pass them as separate objects in the 'asks' array.";
1539
+ 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.";
1369
1540
  var CreateGoalSchema = z3.object({
1370
1541
  outcome: z3.string().trim().min(1).max(1e4),
1371
1542
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -1413,7 +1584,7 @@ var ClaimGoalSchema = z3.object({ goalId: z3.string().uuid().optional() }).stric
1413
1584
  var GetGoalSchema = z3.object({ goalId: z3.string().uuid() }).strict();
1414
1585
  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.";
1415
1586
  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. Returns the Goal as get_goal reads it, at its new revision.";
1416
- var CLAIM_GOAL_DESCRIPTION = "Claim the oldest runnable or review-pending Goal you own, or pass goalId to claim that Goal. Returns the Goal as get_goal reads it, and creates or renews the execution lease.";
1587
+ 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.";
1417
1588
  var CHECK_REPLIES_DESCRIPTION = "Your open Deliveries: every Notification or Call currently addressed to you \u2014 a request the user started toward you, an answer relayed to something you asked, a handoff \u2014 one row each: its Goals, how many decisions are still open, and the newest words in brief. 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 it in full with contact({deliveryId}). Once you have acted on what arrived, update_goal with reviewed: true closes the Deliveries addressed to you on that Goal. Your runnable and review-pending Goals come from claim_goal, not from here.";
1418
1589
  var CheckRepliesSchema = z3.object({}).strict();
1419
1590
  var AGENT_TOOLS = [
@@ -1796,32 +1967,6 @@ var AgendaTurnSchema = z4.object({
1796
1967
  var CLAIM_STALE_MS = 30 * 6e4;
1797
1968
  var InboxItemSchema = z4.object({
1798
1969
  id: z4.string(),
1799
- /** The conversation thread + connection this item lives on. Present on the replied
1800
- * detail — they power History's "Continue" / "New session from this" (#57/#251). */
1801
- parentId: z4.string().optional(),
1802
- /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
1803
- * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
1804
- * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
1805
- * accumulates every message an agent ever sent, so grouping by it renders a day of
1806
- * unrelated updates as a single "12-part request". Absent on rows written before the
1807
- * `asks` table, and on anything that never went through `notify` — both fall back to the
1808
- * thread, which is what the client did for all rows until now. */
1809
- askId: z4.string().optional(),
1810
- /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
1811
- * shares one `created_at` to the microsecond, so without it the author's order is
1812
- * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
1813
- * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
1814
- * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
1815
- seq: z4.number().int().optional(),
1816
- /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
1817
- * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
1818
- * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
1819
- * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
1820
- * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
1821
- * server that can still see every row states it. Absent on any row with no `askId`: a
1822
- * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
1823
- * the one part of its own arrival a single row cannot answer. */
1824
- units: z4.number().int().positive().optional(),
1825
1970
  tokenId: z4.string().optional(),
1826
1971
  status: NotifyStatusSchema,
1827
1972
  context: ContextSchema,
@@ -1866,7 +2011,12 @@ var InboxItemSchema = z4.object({
1866
2011
  entryId: z4.string(),
1867
2012
  title: z4.string(),
1868
2013
  state: z4.enum(["open", "answered"]),
1869
- answer: z4.string().nullable()
2014
+ answer: z4.string().nullable(),
2015
+ /** WHO ASKED IT (owner, 2026-09-23, Goal a345e906: each agenda row wears its agent's face) — the
2016
+ * request Entry's author, as the same three facts the item's own `tokenId`/`name`/`voice`
2017
+ * carry for the call's one agent, so the phone draws it with the same seed. Absent when the
2018
+ * author is not an agent this account holds (unpaired since, or a person). */
2019
+ agent: z4.object({ tokenId: z4.string(), name: z4.string(), voice: VoiceKeySchema.optional() }).optional()
1870
2020
  })).optional(),
1871
2021
  visuals: z4.array(VisualSchema).optional(),
1872
2022
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -1884,9 +2034,14 @@ var InboxItemSchema = z4.object({
1884
2034
  * agent (held in the inbox until the agent replies on the thread). */
1885
2035
  turn: z4.enum(["you", "agent"]).default("you"),
1886
2036
  /** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
1887
- * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
1888
- * client may still flag a stall by age. Drives the inbox error badge + Retry. */
2037
+ * the agent (provider-agnostic; set server-side). Absent = no hard error. Drives the inbox
2038
+ * error badge + Retry. */
1889
2039
  error: z4.string().optional(),
2040
+ /** WHEN THIS AGENT WORK WENT QUIET (turn="agent"), by the one rule (`coldSince`: three days
2041
+ * with nothing said), or absent while it is not stalled. The inbox's stalled badge reads
2042
+ * this and nothing else (2026-09-23: a 3-minute age rule badged every live Goal stalled,
2043
+ * and "dismiss the stalled ones" cancelled 37 pieces of live work). */
2044
+ cold: z4.string().datetime().optional(),
1890
2045
  clarifies: z4.string().optional(),
1891
2046
  /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
1892
2047
  * declined, and what the ladder will do next — read off the cron's own row, never computed
@@ -1936,7 +2091,10 @@ var InboxItemSchema = z4.object({
1936
2091
  goalIds: z4.array(z4.string()),
1937
2092
  decisionNeedId: z4.string().optional(),
1938
2093
  sealed: z4.boolean(),
1939
- goalState: z4.string().optional()
2094
+ goalState: z4.string().optional(),
2095
+ /** THAT GOAL'S NAME (#2416) — what Activity's row is headed by, since a row there is one Goal
2096
+ * and the cards it holds sit behind it. Stamped by the same read as `goalState`. */
2097
+ goalTitle: z4.string().optional()
1940
2098
  }).optional()
1941
2099
  });
1942
2100
  var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
@@ -2035,7 +2193,6 @@ var UserSettingsSchema = z4.object({
2035
2193
  });
2036
2194
  var HistoryItemSchema = z4.object({
2037
2195
  id: z4.string(),
2038
- parentId: z4.string(),
2039
2196
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
2040
2197
  initiator: z4.enum(["user", "agent"]),
2041
2198
  title: z4.string(),
@@ -2194,6 +2351,24 @@ var QueueQuestionSchema = z4.object({
2194
2351
  * gate at all. */
2195
2352
  blocked: z4.boolean().default(false)
2196
2353
  });
2354
+ var QueueReplySchema = z4.object({
2355
+ /** The card this note was (`deliveryId:requestEntryId`, minted by the server like every card
2356
+ * id) — so the phone can tell a reply it just sent from one the queue already carries, and the
2357
+ * walk can name it in its zoom. */
2358
+ id: z4.string(),
2359
+ /** The Goal the note is on. */
2360
+ goalId: z4.string(),
2361
+ /** What the note said. */
2362
+ note: z4.string(),
2363
+ /** Where it was carried — where a second reply goes (`POST /api/entries`, #2252). */
2364
+ deliveryId: z4.string(),
2365
+ requestEntryId: z4.string(),
2366
+ askedAt: z4.string(),
2367
+ /** When the person last replied — the window's start. */
2368
+ repliedAt: z4.string(),
2369
+ /** The person's latest words about it; null when there is nothing readable in them. */
2370
+ reply: z4.string().nullable()
2371
+ });
2197
2372
  var QueueItemSchema = z4.object({
2198
2373
  id: z4.string(),
2199
2374
  /** One-line headline — the first sentence of the outcome. */
@@ -2221,6 +2396,10 @@ var QueueItemSchema = z4.object({
2221
2396
  blocked: z4.boolean().default(false),
2222
2397
  /** Every decision need on it, open or settled — the page decides which to show. */
2223
2398
  questions: z4.array(QueueQuestionSchema).default([]),
2399
+ /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
2400
+ * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
2401
+ * not spell an empty list. */
2402
+ replies: z4.array(QueueReplySchema).optional(),
2224
2403
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
2225
2404
  repo: z4.string().nullable().optional(),
2226
2405
  createdAt: z4.string(),
@@ -2413,6 +2592,7 @@ function sessionSlot(sessionId2) {
2413
2592
  function agentName() {
2414
2593
  return process.env.PAIGY_AGENT || sessionSlot();
2415
2594
  }
2595
+ var PROTO = "paigy-pair-v2|sas=24";
2416
2596
  var TOKEN_PATH = join2(homedir2(), ".paigy", "token.json");
2417
2597
  var KEY_PATH = join2(homedir2(), ".paigy", "key.json");
2418
2598
  function readTokenFile() {
@@ -2453,6 +2633,20 @@ function saveToken(token, agent2 = agentName()) {
2453
2633
  writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
2454
2634
  });
2455
2635
  }
2636
+ function updateSlot(agent2, patch) {
2637
+ return withTokenLock(() => {
2638
+ const slots = readTokenFile();
2639
+ const existing = slots[agent2];
2640
+ if (!existing) return false;
2641
+ slots[agent2] = { ...existing, ...patch };
2642
+ writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
2643
+ return true;
2644
+ });
2645
+ }
2646
+ function slotIdentity(agent2) {
2647
+ const t = readTokenFile()[agent2];
2648
+ return { name: t?.name ?? null, voice: t?.voice ?? null, tokenId: t?.token_id ?? null, workspace: t?.workspace ?? null };
2649
+ }
2456
2650
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2457
2651
  function readToken(agent2 = agentName()) {
2458
2652
  if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
@@ -2603,6 +2797,14 @@ async function setIdentity(patch, opts = {}) {
2603
2797
  if (!res.ok) throw new Error(`set_identity failed: ${res.status} ${await res.text()}`);
2604
2798
  return await res.json();
2605
2799
  }
2800
+ async function claimSessions(opts = {}) {
2801
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/claim`, {
2802
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
2803
+ // the HOST's identity
2804
+ }));
2805
+ if (!res.ok) throw new Error(`claim_sessions failed: ${res.status}`);
2806
+ return (await res.json()).sessions;
2807
+ }
2606
2808
  async function heartbeat(runtime, opts = {}) {
2607
2809
  const body = {
2608
2810
  ...runtime !== void 0 ? { runtime } : {},
@@ -2624,6 +2826,53 @@ async function registerDelivery(mode, opts = {}) {
2624
2826
  if (!res.ok) throw new Error(`register_delivery failed: ${res.status} ${await res.text()}`);
2625
2827
  return await res.json();
2626
2828
  }
2829
+ async function listNotes(opts = {}) {
2830
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notes`, {
2831
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
2832
+ }));
2833
+ if (!res.ok) throw new Error(`list_notes failed: ${res.status} ${await res.text()}`);
2834
+ return await res.json();
2835
+ }
2836
+ async function listConnections(opts = {}) {
2837
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/tokens`, {
2838
+ headers: { authorization: `Bearer ${authToken(opts.token)}`, "x-paigy-model": "goal-entry-v1" }
2839
+ }));
2840
+ if (!res.ok) throw new Error(`list_connections failed: ${res.status} ${await res.text()}`);
2841
+ return await res.json();
2842
+ }
2843
+ async function submitTriage(run, opts = {}) {
2844
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/triage`, {
2845
+ method: "POST",
2846
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
2847
+ body: JSON.stringify(run)
2848
+ }));
2849
+ if (!res.ok) throw new Error(`submit_triage failed: ${res.status} ${await res.text()}`);
2850
+ return await res.json();
2851
+ }
2852
+ async function getTriage(opts = {}) {
2853
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/triage`, {
2854
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
2855
+ }));
2856
+ if (!res.ok) throw new Error(`get_triage failed: ${res.status} ${await res.text()}`);
2857
+ return await res.json();
2858
+ }
2859
+ async function acceptTriage(proposalId, body, opts = {}) {
2860
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/triage/${encodeURIComponent(proposalId)}/accept`, {
2861
+ method: "POST",
2862
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
2863
+ body: JSON.stringify(body)
2864
+ }));
2865
+ if (!res.ok) throw new Error(`accept_triage failed: ${res.status} ${await res.text()}`);
2866
+ return await res.json();
2867
+ }
2868
+ async function dismissTriage(proposalId, opts = {}) {
2869
+ const res = ensureAuthed(await reach(`${BACKEND_URL}/api/triage/${encodeURIComponent(proposalId)}/dismiss`, {
2870
+ method: "POST",
2871
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
2872
+ }));
2873
+ if (!res.ok) throw new Error(`dismiss_triage failed: ${res.status} ${await res.text()}`);
2874
+ return await res.json();
2875
+ }
2627
2876
  function repoFromRemote(url) {
2628
2877
  const said = (url ?? "").trim();
2629
2878
  if (!said) return null;
@@ -2685,6 +2934,9 @@ async function contact(input, opts = {}) {
2685
2934
  if (!res.ok) await fail("batch contact", res);
2686
2935
  const receipt = await res.json();
2687
2936
  deliveries = receipt.deliveries ?? [];
2937
+ if (deliveries.length === 0 && receipt.recorded?.length) {
2938
+ return { recorded: receipt.recorded, message: receipt.message ?? "Recorded as progress on its Goal; nothing was sent to the person." };
2939
+ }
2688
2940
  if (deliveries.length === 0) throw new Error("Goal contact returned no Delivery identities");
2689
2941
  deliveryId = deliveries[0]?.deliveryId ?? "";
2690
2942
  if (!deliveryId) throw new Error("Goal contact returned no valid Delivery identity");
@@ -2776,6 +3028,14 @@ function goalView(g) {
2776
3028
  owner: g.ownerParticipant,
2777
3029
  parentGoalId: g.parentGoalId,
2778
3030
  childGoalIds: g.childGoalIds,
3031
+ // EVERYTHING BELOW, read through this Goal (umbrella reads): one line per Goal, indented by
3032
+ // depth, then each question with its answer — so an agent that asked through an umbrella
3033
+ // reads every answer in one call, whoever owns the child it landed on.
3034
+ subtree: g.subtree?.length ? g.subtree.map((c) => compact({
3035
+ goal: `${" ".repeat(c.depth - 1)}${c.title} (${c.state})`,
3036
+ goalId: c.goalId,
3037
+ questions: c.questions.length ? c.questions.map((q) => q.answer ? `${q.question} \u2192 ${q.answer}` : `${q.question} \u2192 (open)`) : void 0
3038
+ })) : void 0,
2779
3039
  blockedBy: g.blockedDependencyGoalIds,
2780
3040
  dueAt: g.dueAt,
2781
3041
  leaseExpiresAt: g.leaseExpiresAt,
@@ -2797,6 +3057,8 @@ function deliveryView(d) {
2797
3057
  }
2798
3058
  function repliesView(r) {
2799
3059
  const deliveries = r.deliveries ?? [];
3060
+ const stalled = r.stalled ?? [];
3061
+ const others = r.stalledOthers ?? [];
2800
3062
  return {
2801
3063
  deliveries: deliveries.map((d) => compact({
2802
3064
  deliveryId: d.deliveryId,
@@ -2805,15 +3067,26 @@ function repliesView(r) {
2805
3067
  openDecisions: d.decisionNeeds.filter((n) => n.state === "open").length || void 0,
2806
3068
  latest: "about" in d ? d.about : void 0
2807
3069
  })),
2808
- next: deliveries.length ? "Act on a Goal with claim_goal; reread one Delivery in full with contact({deliveryId})." : "Nothing is waiting."
3070
+ // YOUR STALLED WORK (#2257): Goals you own with no progress for three days. Named here so an
3071
+ // agent that reads its replies also sees what it left, and says what to do about each.
3072
+ ...stalled.length ? { stalled } : {},
3073
+ // OTHER AGENTS' STALLED WORK (owner, 2026-09-23): any agent may take it over and close it.
3074
+ ...others.length ? { stalledOthers: others } : {},
3075
+ next: [
3076
+ deliveries.length ? "Act on a Goal with claim_goal; reread one Delivery in full with contact({deliveryId})." : "Nothing is waiting.",
3077
+ ...stalled.length ? [`${stalled.length} of your Goals have had no progress for 3 days: update_goal each with progress, finish it, or cancel it.`] : [],
3078
+ ...others.length ? [`${others.length} of other agents' Goals have gone quiet for 3 days: claim_goal({goalId}) takes one over, then finish or cancel it with update_goal.`] : []
3079
+ ].join(" ")
2809
3080
  };
2810
3081
  }
2811
3082
  async function runTool(name, args, opts) {
2812
3083
  const { waits, signal, ...client } = opts;
2813
3084
  const input = args ?? {};
2814
3085
  switch (name) {
2815
- case "contact":
2816
- return deliveryView(await contact(input, { ...client, signal, waits }));
3086
+ case "contact": {
3087
+ const sent = await contact(input, { ...client, signal, waits });
3088
+ return "recorded" in sent ? sent : deliveryView(sent);
3089
+ }
2817
3090
  case "check_replies":
2818
3091
  CheckRepliesSchema.parse(input);
2819
3092
  return repliesView(await checkReplies(client));
@@ -2935,14 +3208,37 @@ async function subscribeWake(onNudge, opts = {}) {
2935
3208
  }
2936
3209
  };
2937
3210
  }
3211
+ function parseDue(detail, now = /* @__PURE__ */ new Date()) {
3212
+ const tz = machineZone();
3213
+ if (!tz) return null;
3214
+ return parseDelay(detail, tz, now.getTime(), "ring");
3215
+ }
3216
+ function machineZone() {
3217
+ try {
3218
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
3219
+ } catch {
3220
+ return null;
3221
+ }
3222
+ }
2938
3223
 
2939
3224
  export {
3225
+ BACKEND_URL,
3226
+ NETWORK_MSG,
2940
3227
  sessionId,
2941
3228
  isNetworkError,
3229
+ reach,
3230
+ reachAs,
3231
+ AGENT_TOOLS,
3232
+ AGENT_TOOL_NAMES,
3233
+ NotifyRequestSchema,
2942
3234
  sessionSlot,
2943
3235
  agentName,
3236
+ PROTO,
2944
3237
  TOKEN_PATH,
3238
+ KEY_PATH,
2945
3239
  saveToken,
3240
+ updateSlot,
3241
+ slotIdentity,
2946
3242
  sleep,
2947
3243
  readToken,
2948
3244
  listSlots,
@@ -2952,14 +3248,31 @@ export {
2952
3248
  requestCode,
2953
3249
  pollToken,
2954
3250
  UnpairedError,
3251
+ ApiError,
2955
3252
  overrideToken,
3253
+ authToken,
3254
+ createGoal,
3255
+ claimGoal,
2956
3256
  getGoal,
3257
+ updateGoal,
3258
+ AWAIT_WINDOW_MS,
2957
3259
  hatch,
2958
3260
  whoAmI,
2959
3261
  setIdentity,
3262
+ claimSessions,
2960
3263
  heartbeat,
3264
+ registerDelivery,
3265
+ listNotes,
3266
+ listConnections,
3267
+ submitTriage,
3268
+ getTriage,
3269
+ acceptTriage,
3270
+ dismissTriage,
3271
+ repoFromRemote,
3272
+ currentRepo,
2961
3273
  contact,
2962
3274
  checkReplies,
2963
3275
  runTool,
2964
- subscribeWake
3276
+ subscribeWake,
3277
+ parseDue
2965
3278
  };
@@ -57,7 +57,7 @@ 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
- "The question, and only what is needed to answer it. News, progress and findings are their own contact \u2014 a call contact JOINS a call already happening, so several arrive as one call. Do not bundle: a DecisionNeed is settled only by an answer in the shape this ask declares, so someone who answers the part that interested them settles nothing and is asked again."
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."
61
61
  ),
62
62
  options: z2.array(OptionInputSchema).min(2).max(6).optional()
63
63
  }).strict();
@@ -68,7 +68,7 @@ var StartContactSchema = z2.object({
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}. Never bundle multiple questions into a single 'ask' string; pass them as separate objects in the 'asks' array.";
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.";
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
@@ -116,7 +116,7 @@ var ClaimGoalSchema = z3.object({ goalId: z3.string().uuid().optional() }).stric
116
116
  var GetGoalSchema = z3.object({ goalId: z3.string().uuid() }).strict();
117
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
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. Returns the Goal as get_goal reads it, at its new revision.";
119
- var CLAIM_GOAL_DESCRIPTION = "Claim the oldest runnable or review-pending Goal you own, or pass goalId to claim that Goal. Returns the Goal as get_goal reads it, and creates or renews the execution lease.";
119
+ 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
120
  var CHECK_REPLIES_DESCRIPTION = "Your open Deliveries: every Notification or Call currently addressed to you \u2014 a request the user started toward you, an answer relayed to something you asked, a handoff \u2014 one row each: its Goals, how many decisions are still open, and the newest words in brief. 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 it in full with contact({deliveryId}). Once you have acted on what arrived, update_goal with reviewed: true closes the Deliveries addressed to you on that Goal. Your runnable and review-pending Goals come from claim_goal, not from here.";
121
121
  var CheckRepliesSchema = z3.object({}).strict();
122
122
  var AGENT_TOOLS = [
@@ -509,32 +509,6 @@ var AgendaTurnSchema = z4.object({
509
509
  var CLAIM_STALE_MS = 30 * 6e4;
510
510
  var InboxItemSchema = z4.object({
511
511
  id: z4.string(),
512
- /** The conversation thread + connection this item lives on. Present on the replied
513
- * detail — they power History's "Continue" / "New session from this" (#57/#251). */
514
- parentId: z4.string().optional(),
515
- /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
516
- * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
517
- * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
518
- * accumulates every message an agent ever sent, so grouping by it renders a day of
519
- * unrelated updates as a single "12-part request". Absent on rows written before the
520
- * `asks` table, and on anything that never went through `notify` — both fall back to the
521
- * thread, which is what the client did for all rows until now. */
522
- askId: z4.string().optional(),
523
- /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
524
- * shares one `created_at` to the microsecond, so without it the author's order is
525
- * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
526
- * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
527
- * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
528
- seq: z4.number().int().optional(),
529
- /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
530
- * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
531
- * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
532
- * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
533
- * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
534
- * server that can still see every row states it. Absent on any row with no `askId`: a
535
- * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
536
- * the one part of its own arrival a single row cannot answer. */
537
- units: z4.number().int().positive().optional(),
538
512
  tokenId: z4.string().optional(),
539
513
  status: NotifyStatusSchema,
540
514
  context: ContextSchema,
@@ -579,7 +553,12 @@ var InboxItemSchema = z4.object({
579
553
  entryId: z4.string(),
580
554
  title: z4.string(),
581
555
  state: z4.enum(["open", "answered"]),
582
- answer: z4.string().nullable()
556
+ answer: z4.string().nullable(),
557
+ /** WHO ASKED IT (owner, 2026-09-23, Goal a345e906: each agenda row wears its agent's face) — the
558
+ * request Entry's author, as the same three facts the item's own `tokenId`/`name`/`voice`
559
+ * carry for the call's one agent, so the phone draws it with the same seed. Absent when the
560
+ * 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()
583
562
  })).optional(),
584
563
  visuals: z4.array(VisualSchema).optional(),
585
564
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -597,9 +576,14 @@ var InboxItemSchema = z4.object({
597
576
  * agent (held in the inbox until the agent replies on the thread). */
598
577
  turn: z4.enum(["you", "agent"]).default("you"),
599
578
  /** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
600
- * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
601
- * client may still flag a stall by age. Drives the inbox error badge + Retry. */
579
+ * the agent (provider-agnostic; set server-side). Absent = no hard error. Drives the inbox
580
+ * error badge + Retry. */
602
581
  error: z4.string().optional(),
582
+ /** WHEN THIS AGENT WORK WENT QUIET (turn="agent"), by the one rule (`coldSince`: three days
583
+ * with nothing said), or absent while it is not stalled. The inbox's stalled badge reads
584
+ * this and nothing else (2026-09-23: a 3-minute age rule badged every live Goal stalled,
585
+ * and "dismiss the stalled ones" cancelled 37 pieces of live work). */
586
+ cold: z4.string().datetime().optional(),
603
587
  clarifies: z4.string().optional(),
604
588
  /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
605
589
  * declined, and what the ladder will do next — read off the cron's own row, never computed
@@ -649,7 +633,10 @@ var InboxItemSchema = z4.object({
649
633
  goalIds: z4.array(z4.string()),
650
634
  decisionNeedId: z4.string().optional(),
651
635
  sealed: z4.boolean(),
652
- goalState: z4.string().optional()
636
+ goalState: z4.string().optional(),
637
+ /** THAT GOAL'S NAME (#2416) — what Activity's row is headed by, since a row there is one Goal
638
+ * and the cards it holds sit behind it. Stamped by the same read as `goalState`. */
639
+ goalTitle: z4.string().optional()
653
640
  }).optional()
654
641
  });
655
642
  var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
@@ -748,7 +735,6 @@ var UserSettingsSchema = z4.object({
748
735
  });
749
736
  var HistoryItemSchema = z4.object({
750
737
  id: z4.string(),
751
- parentId: z4.string(),
752
738
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
753
739
  initiator: z4.enum(["user", "agent"]),
754
740
  title: z4.string(),
@@ -907,6 +893,24 @@ var QueueQuestionSchema = z4.object({
907
893
  * gate at all. */
908
894
  blocked: z4.boolean().default(false)
909
895
  });
896
+ var QueueReplySchema = z4.object({
897
+ /** The card this note was (`deliveryId:requestEntryId`, minted by the server like every card
898
+ * id) — so the phone can tell a reply it just sent from one the queue already carries, and the
899
+ * walk can name it in its zoom. */
900
+ id: z4.string(),
901
+ /** The Goal the note is on. */
902
+ goalId: z4.string(),
903
+ /** What the note said. */
904
+ note: z4.string(),
905
+ /** Where it was carried — where a second reply goes (`POST /api/entries`, #2252). */
906
+ deliveryId: z4.string(),
907
+ requestEntryId: z4.string(),
908
+ askedAt: z4.string(),
909
+ /** When the person last replied — the window's start. */
910
+ repliedAt: z4.string(),
911
+ /** The person's latest words about it; null when there is nothing readable in them. */
912
+ reply: z4.string().nullable()
913
+ });
910
914
  var QueueItemSchema = z4.object({
911
915
  id: z4.string(),
912
916
  /** One-line headline — the first sentence of the outcome. */
@@ -934,6 +938,10 @@ var QueueItemSchema = z4.object({
934
938
  blocked: z4.boolean().default(false),
935
939
  /** Every decision need on it, open or settled — the page decides which to show. */
936
940
  questions: z4.array(QueueQuestionSchema).default([]),
941
+ /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
942
+ * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
943
+ * not spell an empty list. */
944
+ replies: z4.array(QueueReplySchema).optional(),
937
945
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
938
946
  repo: z4.string().nullable().optional(),
939
947
  createdAt: z4.string(),
@@ -4,7 +4,7 @@ import {
4
4
  requestCode,
5
5
  saveToken,
6
6
  sleep
7
- } from "./chunk-R2GTL7N3.js";
7
+ } from "./chunk-PHTQITTC.js";
8
8
 
9
9
  // src/identity.ts
10
10
  var CLIENT_LABELS = {
@@ -0,0 +1,110 @@
1
+ import {
2
+ AGENT_TOOLS,
3
+ AGENT_TOOL_NAMES,
4
+ AWAIT_WINDOW_MS,
5
+ ApiError,
6
+ BACKEND_URL,
7
+ KEY_PATH,
8
+ NETWORK_MSG,
9
+ NotifyRequestSchema,
10
+ PROTO,
11
+ TOKEN_PATH,
12
+ UnpairedError,
13
+ acceptTriage,
14
+ agentName,
15
+ authToken,
16
+ checkReplies,
17
+ claimGoal,
18
+ claimSessions,
19
+ contact,
20
+ createGoal,
21
+ currentRepo,
22
+ deleteToken,
23
+ dismissTriage,
24
+ getGoal,
25
+ getTriage,
26
+ hatch,
27
+ heartbeat,
28
+ isNetworkError,
29
+ listConnections,
30
+ listNotes,
31
+ listSlots,
32
+ overrideToken,
33
+ parseDue,
34
+ pollToken,
35
+ reach,
36
+ reachAs,
37
+ readToken,
38
+ registerDelivery,
39
+ repoFromRemote,
40
+ requestCode,
41
+ revokeToken,
42
+ runTool,
43
+ saveToken,
44
+ sessionId,
45
+ sessionSlot,
46
+ setIdentity,
47
+ sleep,
48
+ slotIdentity,
49
+ slotName,
50
+ submitTriage,
51
+ subscribeWake,
52
+ updateGoal,
53
+ updateSlot,
54
+ whoAmI
55
+ } from "./chunk-PHTQITTC.js";
56
+ export {
57
+ AGENT_TOOLS,
58
+ AGENT_TOOL_NAMES,
59
+ AWAIT_WINDOW_MS,
60
+ ApiError,
61
+ BACKEND_URL,
62
+ KEY_PATH,
63
+ NETWORK_MSG,
64
+ NotifyRequestSchema,
65
+ PROTO,
66
+ TOKEN_PATH,
67
+ UnpairedError,
68
+ acceptTriage,
69
+ agentName,
70
+ authToken,
71
+ checkReplies,
72
+ claimGoal,
73
+ claimSessions,
74
+ contact,
75
+ createGoal,
76
+ currentRepo,
77
+ deleteToken,
78
+ dismissTriage,
79
+ getGoal,
80
+ getTriage,
81
+ hatch,
82
+ heartbeat,
83
+ isNetworkError,
84
+ listConnections,
85
+ listNotes,
86
+ listSlots,
87
+ overrideToken,
88
+ parseDue,
89
+ pollToken,
90
+ reach,
91
+ reachAs,
92
+ readToken,
93
+ registerDelivery,
94
+ repoFromRemote,
95
+ requestCode,
96
+ revokeToken,
97
+ runTool,
98
+ saveToken,
99
+ sessionId,
100
+ sessionSlot,
101
+ setIdentity,
102
+ sleep,
103
+ slotIdentity,
104
+ slotName,
105
+ submitTriage,
106
+ subscribeWake,
107
+ updateGoal,
108
+ updateSlot,
109
+ whoAmI
110
+ };
package/dist/enable.js CHANGED
@@ -3,9 +3,9 @@ import {
3
3
  PAIGY_TOOL_IDS,
4
4
  enablePaigyTools,
5
5
  installSessionListening
6
- } from "./chunk-SO5NRTXG.js";
7
- import "./chunk-NOZRU5CD.js";
8
- import "./chunk-R2GTL7N3.js";
6
+ } from "./chunk-37FS33UP.js";
7
+ import "./chunk-SE2JCKDI.js";
8
+ import "./chunk-PHTQITTC.js";
9
9
 
10
10
  // src/enable.ts
11
11
  function main() {
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  resolvePairing,
6
6
  startPairing,
7
7
  suggestedAgentName
8
- } from "./chunk-SKLVQ73Q.js";
8
+ } from "./chunk-X6G7XXCP.js";
9
9
  import {
10
10
  ENABLE_COMMAND,
11
11
  OnboardSchema,
@@ -14,11 +14,11 @@ import {
14
14
  SERVER_INSTRUCTIONS,
15
15
  TOOLS,
16
16
  paigyToolsAllowlisted
17
- } from "./chunk-SO5NRTXG.js";
17
+ } from "./chunk-37FS33UP.js";
18
18
  import {
19
19
  decideListen,
20
20
  listenerAlive
21
- } from "./chunk-NOZRU5CD.js";
21
+ } from "./chunk-SE2JCKDI.js";
22
22
  import {
23
23
  clearSurface,
24
24
  writeSurface
@@ -39,7 +39,7 @@ import {
39
39
  setIdentity,
40
40
  slotName,
41
41
  whoAmI
42
- } from "./chunk-R2GTL7N3.js";
42
+ } from "./chunk-PHTQITTC.js";
43
43
 
44
44
  // src/index.ts
45
45
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
package/dist/listen.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  entryWords,
4
4
  removeListenMark,
5
5
  writeListenMark
6
- } from "./chunk-NOZRU5CD.js";
6
+ } from "./chunk-SE2JCKDI.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-R2GTL7N3.js";
14
+ } from "./chunk-PHTQITTC.js";
15
15
 
16
16
  // src/listen.ts
17
17
  import { spawn } from "child_process";
@@ -132,11 +132,12 @@ function leadOf(deliveries, goalId) {
132
132
  }
133
133
  function brief(waiting, reason, as, goalId) {
134
134
  const lead = leadOf(waiting.deliveries, goalId);
135
- if (!lead) return reason === "boot" ? `Paigy \xB7 listening as ${as} \xB7 nothing waiting` : `Paigy \xB7 ${reason} \xB7 nothing waiting`;
135
+ const stalled = waiting.stalled?.length ? ` \xB7 ${waiting.stalled.length} of your Goals stalled 3+ days: update, finish or cancel each` : "";
136
+ if (!lead) return (reason === "boot" ? `Paigy \xB7 listening as ${as} \xB7 nothing waiting` : `Paigy \xB7 ${reason} \xB7 nothing waiting`) + stalled;
136
137
  const said = saidIn(lead) || ("about" in lead ? lead.about : "");
137
138
  const shown = said.length > 160 ? `${said.slice(0, 160)}\u2026` : said;
138
139
  const goal = waiting.goal?.goalId ?? lead.goalIds[0];
139
- return `Paigy \xB7 ${reason} \xB7 ${waiting.deliveries.length} waiting \xB7 lead ${lead.deliveryId} on Goal ${goal}: "${shown}"`;
140
+ return `Paigy \xB7 ${reason} \xB7 ${waiting.deliveries.length} waiting \xB7 lead ${lead.deliveryId} on Goal ${goal}: "${shown}"${stalled}`;
140
141
  }
141
142
  function launchEnv(waiting, reason, goalId) {
142
143
  const env = { PAIGY_WORK: JSON.stringify(waiting), PAIGY_EVENT: reason };
@@ -153,16 +154,16 @@ function launchEnv(waiting, reason, goalId) {
153
154
  async function sweep(reason, goalId) {
154
155
  try {
155
156
  const cmd = process.env.PAIGY_ON_WAKE;
156
- const { deliveries: rows } = await checkReplies();
157
+ const { deliveries: rows, stalled } = await checkReplies();
157
158
  const lead = leadOf(rows, goalId);
158
- const full = lead ? await contact({ deliveryId: lead.deliveryId }, { waits: false }).catch(() => null) : null;
159
+ const full = lead ? await contact({ deliveryId: lead.deliveryId }, { waits: false }).then((d) => "recorded" in d ? null : d).catch(() => null) : null;
159
160
  const deliveries = rows.map((d) => d === lead && full ? full : d);
160
161
  const leadGoalId = lead?.goalIds[0];
161
162
  const goal = leadGoalId ? await getGoal(leadGoalId).then((g) => ({ goalId: leadGoalId, progress: g.progress })).catch(() => null) : null;
162
- const waiting = { deliveries, goal };
163
+ const waiting = { deliveries, goal, ...stalled?.length ? { stalled } : {} };
163
164
  if (BRIEF) line(brief(waiting, reason, listeningAs(), goalId));
164
165
  else emit({ type: "sweep", reason, ...goalId ? { about: goalId } : {}, ...waiting });
165
- if (cmd && deliveries.length) {
166
+ if (cmd && (deliveries.length || reason === "boot" && waiting.stalled?.length)) {
166
167
  spawn(cmd, { shell: true, stdio: "inherit", env: { ...process.env, ...launchEnv(waiting, reason, goalId) } });
167
168
  }
168
169
  } catch (e) {
package/dist/onboard.js CHANGED
@@ -3,13 +3,13 @@ import {
3
3
  resolvePairing,
4
4
  startPairing,
5
5
  suggestedAgentName
6
- } from "./chunk-SKLVQ73Q.js";
6
+ } from "./chunk-X6G7XXCP.js";
7
7
  import {
8
8
  autoConfigureClients,
9
9
  claudeInstallHint,
10
10
  openBrowser
11
- } from "./chunk-SO5NRTXG.js";
12
- import "./chunk-NOZRU5CD.js";
11
+ } from "./chunk-37FS33UP.js";
12
+ import "./chunk-SE2JCKDI.js";
13
13
  import {
14
14
  TOKEN_PATH,
15
15
  agentName,
@@ -20,7 +20,7 @@ import {
20
20
  saveToken,
21
21
  setIdentity,
22
22
  whoAmI
23
- } from "./chunk-R2GTL7N3.js";
23
+ } from "./chunk-PHTQITTC.js";
24
24
 
25
25
  // src/onboard.ts
26
26
  function reportRegistered() {
package/dist/slot.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  agentName
4
- } from "./chunk-R2GTL7N3.js";
4
+ } from "./chunk-PHTQITTC.js";
5
5
 
6
6
  // src/slot.ts
7
7
  process.stdout.write(agentName());
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/stalled.ts
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ function stopReply(stalled) {
8
+ if (!stalled.length) return null;
9
+ const list = stalled.slice(0, 8).map((g) => `"${g.title}" (${g.goalId})`).join(", ");
10
+ const more = stalled.length > 8 ? ` and ${stalled.length - 8} more` : "";
11
+ return {
12
+ decision: "block",
13
+ reason: `Before you stop: ${stalled.length} of your Paigy Goals have had no progress for 3 days: ${list}${more}. For each, update_goal with progress, finish it, or cancel it.`
14
+ };
15
+ }
16
+ async function main() {
17
+ const input = JSON.parse(readFileSync(0, "utf8") || "{}");
18
+ if (input.stop_hook_active) return;
19
+ const session = input.session_id ?? "";
20
+ if (session) process.env.PAIGY_SESSION_ID = session;
21
+ const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
22
+ const dir = join(homedir(), ".paigy", "stalled-reminded");
23
+ const mark = join(dir, `${session || "session"}-${day}`);
24
+ if (existsSync(mark)) return;
25
+ const { checkReplies } = await import("./dist-DKP3DJQJ.js");
26
+ const reply = stopReply((await checkReplies()).stalled ?? []);
27
+ if (!reply) return;
28
+ mkdirSync(dir, { recursive: true });
29
+ writeFileSync(mark, "");
30
+ process.stdout.write(JSON.stringify(reply));
31
+ }
32
+ if (process.argv[1] && /stalled(\.[jt]s)?$/.test(process.argv[1])) main().catch(() => void 0);
33
+ export {
34
+ stopReply
35
+ };
@@ -7,7 +7,7 @@ import {
7
7
  readToken,
8
8
  sessionSlot,
9
9
  slotName
10
- } from "./chunk-R2GTL7N3.js";
10
+ } from "./chunk-PHTQITTC.js";
11
11
 
12
12
  // src/statusline.ts
13
13
  import { realpathSync } from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/mcp",
3
- "version": "0.40.14",
3
+ "version": "0.40.15",
4
4
  "description": "Paigy MCP server — the AI agent harness that calls you. Lets an agent notify a user and await their reply.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,8 @@
11
11
  "paigy-listen": "dist/listen.js",
12
12
  "paigy-statusline": "dist/statusline.js",
13
13
  "paigy-enable-tools": "dist/enable.js",
14
- "paigy-slot": "dist/slot.js"
14
+ "paigy-slot": "dist/slot.js",
15
+ "paigy-stalled": "dist/stalled.js"
15
16
  },
16
17
  "files": [
17
18
  "dist"