@paigy/harness 0.3.15 → 0.3.16

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.
Files changed (3) hide show
  1. package/dist/cli.js +226 -122
  2. package/dist/main.js +226 -122
  3. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -4580,7 +4580,7 @@ import { app, BrowserWindow, dialog, ipcMain } from "electron";
4580
4580
  import { spawn as spawn2 } from "child_process";
4581
4581
  import { readFileSync as readFileSync5 } from "fs";
4582
4582
  import { fileURLToPath } from "url";
4583
- import { dirname as dirname4, join as join8 } from "path";
4583
+ import { dirname as dirname4, join as join9 } from "path";
4584
4584
 
4585
4585
  // ../../packages/sdk/dist/index.js
4586
4586
  import { createRequire as __sdkCreateRequire } from "module";
@@ -9973,7 +9973,7 @@ var AskInputSchema = external_exports.object({
9973
9973
  parentId: external_exports.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."),
9974
9974
  repo: external_exports.string().optional().describe("Optional repository context."),
9975
9975
  ask: external_exports.string().trim().min(1).max(1e4).describe(
9976
- "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."
9976
+ "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."
9977
9977
  ),
9978
9978
  options: external_exports.array(OptionInputSchema).min(2).max(6).optional()
9979
9979
  }).strict();
@@ -9984,7 +9984,7 @@ var StartContactSchema = external_exports.object({
9984
9984
  }).strict();
9985
9985
  var ContactSchema = external_exports.union([StartContactSchema, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
9986
9986
  var CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
9987
- 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.";
9987
+ 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).";
9988
9988
  var CreateGoalSchema = external_exports.object({
9989
9989
  outcome: external_exports.string().trim().min(1).max(1e4),
9990
9990
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -10031,9 +10031,9 @@ var UpdateGoalToolSchema = UpdateGoalSchema.omit({ operationId: true }).extend({
10031
10031
  var ClaimGoalSchema = external_exports.object({ goalId: external_exports.string().uuid().optional() }).strict();
10032
10032
  var GetGoalSchema = external_exports.object({ goalId: external_exports.string().uuid() }).strict();
10033
10033
  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.";
10034
- 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.";
10035
- 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.";
10036
- 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.";
10034
+ 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.`;
10035
+ 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.";
10036
+ 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.";
10037
10037
  var CheckRepliesSchema = external_exports.object({}).strict();
10038
10038
  var AGENT_TOOLS = [
10039
10039
  { name: "contact", description: CONTACT_DESCRIPTION, inputSchema: CONTACT_SCHEMA },
@@ -10363,9 +10363,9 @@ var AgendaTurnSchema = external_exports.object({
10363
10363
  /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
10364
10364
  * the planner declares duplicates instead of asking them twice. */
10365
10365
  coveredIds: external_exports.array(external_exports.string()).optional(),
10366
- /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
10367
- * line went out on 2026-07-28 and the caller could not answer it at all. */
10368
- info: external_exports.array(external_exports.string().min(1)).max(3).default([]),
10366
+ /** The spoken sentences of the turn, in order. No count: how long a turn is is the brain's call
10367
+ * (owner, 2026-09-25), and a count here refused whole plans. */
10368
+ info: external_exports.array(external_exports.string().min(1)).default([]),
10369
10369
  question: external_exports.string().min(1).nullable(),
10370
10370
  /** True on the one turn carrying the agent's own declared question. */
10371
10371
  asks: external_exports.boolean().optional(),
@@ -10400,32 +10400,6 @@ var AgendaTurnSchema = external_exports.object({
10400
10400
  var CLAIM_STALE_MS = 30 * 6e4;
10401
10401
  var InboxItemSchema = external_exports.object({
10402
10402
  id: external_exports.string(),
10403
- /** The conversation thread + connection this item lives on. Present on the replied
10404
- * detail — they power History's "Continue" / "New session from this" (#57/#251). */
10405
- parentId: external_exports.string().optional(),
10406
- /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
10407
- * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
10408
- * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
10409
- * accumulates every message an agent ever sent, so grouping by it renders a day of
10410
- * unrelated updates as a single "12-part request". Absent on rows written before the
10411
- * `asks` table, and on anything that never went through `notify` — both fall back to the
10412
- * thread, which is what the client did for all rows until now. */
10413
- askId: external_exports.string().optional(),
10414
- /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
10415
- * shares one `created_at` to the microsecond, so without it the author's order is
10416
- * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
10417
- * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
10418
- * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
10419
- seq: external_exports.number().int().optional(),
10420
- /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
10421
- * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
10422
- * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
10423
- * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
10424
- * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
10425
- * server that can still see every row states it. Absent on any row with no `askId`: a
10426
- * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
10427
- * the one part of its own arrival a single row cannot answer. */
10428
- units: external_exports.number().int().positive().optional(),
10429
10403
  tokenId: external_exports.string().optional(),
10430
10404
  status: NotifyStatusSchema,
10431
10405
  context: ContextSchema,
@@ -10470,7 +10444,12 @@ var InboxItemSchema = external_exports.object({
10470
10444
  entryId: external_exports.string(),
10471
10445
  title: external_exports.string(),
10472
10446
  state: external_exports.enum(["open", "answered"]),
10473
- answer: external_exports.string().nullable()
10447
+ answer: external_exports.string().nullable(),
10448
+ /** WHO ASKED IT (owner, 2026-09-23, Goal a345e906: each agenda row wears its agent's face) — the
10449
+ * request Entry's author, as the same three facts the item's own `tokenId`/`name`/`voice`
10450
+ * carry for the call's one agent, so the phone draws it with the same seed. Absent when the
10451
+ * author is not an agent this account holds (unpaired since, or a person). */
10452
+ agent: external_exports.object({ tokenId: external_exports.string(), name: external_exports.string(), voice: VoiceKeySchema.optional() }).optional()
10474
10453
  })).optional(),
10475
10454
  visuals: external_exports.array(VisualSchema).optional(),
10476
10455
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -10488,10 +10467,20 @@ var InboxItemSchema = external_exports.object({
10488
10467
  * agent (held in the inbox until the agent replies on the thread). */
10489
10468
  turn: external_exports.enum(["you", "agent"]).default("you"),
10490
10469
  /** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
10491
- * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
10492
- * client may still flag a stall by age. Drives the inbox error badge + Retry. */
10470
+ * the agent (provider-agnostic; set server-side). Absent = no hard error. Drives the inbox
10471
+ * error badge + Retry. */
10493
10472
  error: external_exports.string().optional(),
10473
+ /** WHEN THIS AGENT WORK WENT QUIET (turn="agent"), by the one rule (`coldSince`: three days
10474
+ * with nothing said), or absent while it is not stalled. The inbox's stalled badge reads
10475
+ * this and nothing else (2026-09-23: a 3-minute age rule badged every live Goal stalled,
10476
+ * and "dismiss the stalled ones" cancelled 37 pieces of live work). */
10477
+ cold: external_exports.string().datetime().optional(),
10494
10478
  clarifies: external_exports.string().optional(),
10479
+ /** THIS CARD'S QUESTION IS ON A LIVE CALL (owner, 2026-09-24: "Mark it while the call is
10480
+ * live"). Present only while an open Call Delivery carries the card's request Entry — read
10481
+ * off the same open list the card came from, so it clears when the Call does. A card is the
10482
+ * backup for a call not taken; while the call has it, the call is where it is answered. */
10483
+ onCall: external_exports.literal(true).optional(),
10495
10484
  /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
10496
10485
  * declined, and what the ladder will do next — read off the cron's own row, never computed
10497
10486
  * on the phone. Present only while a `declined` receipt stands on the card's last Call.
@@ -10540,7 +10529,10 @@ var InboxItemSchema = external_exports.object({
10540
10529
  goalIds: external_exports.array(external_exports.string()),
10541
10530
  decisionNeedId: external_exports.string().optional(),
10542
10531
  sealed: external_exports.boolean(),
10543
- goalState: external_exports.string().optional()
10532
+ goalState: external_exports.string().optional(),
10533
+ /** THAT GOAL'S NAME (#2416) — what Activity's row is headed by, since a row there is one Goal
10534
+ * and the cards it holds sit behind it. Stamped by the same read as `goalState`. */
10535
+ goalTitle: external_exports.string().optional()
10544
10536
  }).optional()
10545
10537
  });
10546
10538
  var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
@@ -10639,7 +10631,6 @@ var UserSettingsSchema = external_exports.object({
10639
10631
  });
10640
10632
  var HistoryItemSchema = external_exports.object({
10641
10633
  id: external_exports.string(),
10642
- parentId: external_exports.string(),
10643
10634
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
10644
10635
  initiator: external_exports.enum(["user", "agent"]),
10645
10636
  title: external_exports.string(),
@@ -10798,6 +10789,24 @@ var QueueQuestionSchema = external_exports.object({
10798
10789
  * gate at all. */
10799
10790
  blocked: external_exports.boolean().default(false)
10800
10791
  });
10792
+ var QueueReplySchema = external_exports.object({
10793
+ /** The card this note was (`deliveryId:requestEntryId`, minted by the server like every card
10794
+ * id) — so the phone can tell a reply it just sent from one the queue already carries, and the
10795
+ * walk can name it in its zoom. */
10796
+ id: external_exports.string(),
10797
+ /** The Goal the note is on. */
10798
+ goalId: external_exports.string(),
10799
+ /** What the note said. */
10800
+ note: external_exports.string(),
10801
+ /** Where it was carried — where a second reply goes (`POST /api/entries`, #2252). */
10802
+ deliveryId: external_exports.string(),
10803
+ requestEntryId: external_exports.string(),
10804
+ askedAt: external_exports.string(),
10805
+ /** When the person last replied — the window's start. */
10806
+ repliedAt: external_exports.string(),
10807
+ /** The person's latest words about it; null when there is nothing readable in them. */
10808
+ reply: external_exports.string().nullable()
10809
+ });
10801
10810
  var QueueItemSchema = external_exports.object({
10802
10811
  id: external_exports.string(),
10803
10812
  /** One-line headline — the first sentence of the outcome. */
@@ -10825,6 +10834,10 @@ var QueueItemSchema = external_exports.object({
10825
10834
  blocked: external_exports.boolean().default(false),
10826
10835
  /** Every decision need on it, open or settled — the page decides which to show. */
10827
10836
  questions: external_exports.array(QueueQuestionSchema).default([]),
10837
+ /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
10838
+ * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
10839
+ * not spell an empty list. */
10840
+ replies: external_exports.array(QueueReplySchema).optional(),
10828
10841
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
10829
10842
  repo: external_exports.string().nullable().optional(),
10830
10843
  createdAt: external_exports.string(),
@@ -11285,6 +11298,7 @@ function currentRepo(cwd = process.cwd()) {
11285
11298
  }
11286
11299
  return asked;
11287
11300
  }
11301
+ var NOT_SENT = "Recorded as progress, not sent: nobody was notified. If the person should see it (a result, something to try or check, a finding), contact again and say so; report progress with update_goal.";
11288
11302
  async function contact(input, opts = {}) {
11289
11303
  const parsed = ContactSchema.parse(input);
11290
11304
  opts.signal?.throwIfAborted();
@@ -11292,6 +11306,7 @@ async function contact(input, opts = {}) {
11292
11306
  const headers = { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token) ?? ""}`, "x-paigy-model": "goal-entry-v1" };
11293
11307
  let deliveryId;
11294
11308
  let deliveries = [];
11309
+ let recorded = [];
11295
11310
  if ("deliveryId" in parsed) {
11296
11311
  deliveryId = parsed.deliveryId;
11297
11312
  } else {
@@ -11320,16 +11335,24 @@ async function contact(input, opts = {}) {
11320
11335
  if (!res.ok) await fail("batch contact", res);
11321
11336
  const receipt = await res.json();
11322
11337
  deliveries = receipt.deliveries ?? [];
11338
+ recorded = receipt.recorded ?? [];
11339
+ if (deliveries.length === 0 && recorded.length) return { sent: false, recorded, message: NOT_SENT };
11323
11340
  if (deliveries.length === 0) throw new Error("Goal contact returned no Delivery identities");
11324
11341
  deliveryId = deliveries[0]?.deliveryId ?? "";
11325
11342
  if (!deliveryId) throw new Error("Goal contact returned no valid Delivery identity");
11326
11343
  }
11327
- const all = deliveries.filter((d) => !!d.deliveryId && !!d.goalId);
11344
+ const all = deliveries.filter((d) => !!d.deliveryId && !!d.goalId).map(({ id, deliveryId: deliveryId2, goalId }) => ({ ...id ? { id } : {}, deliveryId: deliveryId2, goalId }));
11345
+ const joinedCard = deliveries[0]?.joinedCard === true;
11328
11346
  const read = async (signal2) => {
11329
11347
  const res = ensureAuthed(await send22(`${BACKEND_URL}/api/deliveries/${encodeURIComponent(deliveryId)}`, { headers, signal: signal2 }));
11330
11348
  if (!res.ok) await fail("read_delivery", res);
11331
11349
  const one = await res.json();
11332
- return all.length > 1 ? { ...one, deliveries: all } : one;
11350
+ return {
11351
+ ...one,
11352
+ ...all.length > 1 ? { deliveries: all } : {},
11353
+ ...recorded.length ? { recorded, notSent: NOT_SENT } : {},
11354
+ ...joinedCard ? { joinedCard: true } : {}
11355
+ };
11333
11356
  };
11334
11357
  const settled = (d) => d.kind === "notification" || d.state === "closed" || d.answers.length > 0 || d.entries.some((e) => e.kind === "contribution");
11335
11358
  if (opts.waits === false) return read(opts.signal);
@@ -11359,47 +11382,108 @@ async function checkReplies(opts = {}) {
11359
11382
  var HEARTBEAT_MS = 25e3;
11360
11383
  var RETRY_MS = [1e3, 2e3, 5e3, 1e4, 3e4];
11361
11384
  var JOIN = { config: { broadcast: { self: false } } };
11385
+ var JOIN_TIMEOUT_MS = 1e4;
11362
11386
  var lines = /* @__PURE__ */ new Map();
11363
11387
  function frame(line, topic, event, payload) {
11388
+ const ref = String(++line.ref);
11364
11389
  try {
11365
- line.ws?.send(JSON.stringify({ topic, event, payload, ref: String(++line.ref) }));
11390
+ line.ws?.send(JSON.stringify({ topic, event, payload, ref }));
11366
11391
  } catch {
11367
11392
  }
11393
+ return ref;
11368
11394
  }
11369
11395
  function stop(line) {
11370
11396
  line.open = false;
11397
+ line.beat = void 0;
11371
11398
  clearInterval(line.heart);
11372
11399
  clearTimeout(line.retry);
11400
+ for (const t of line.topics.values()) {
11401
+ clearTimeout(t.timer);
11402
+ t.joined = false;
11403
+ t.ref = void 0;
11404
+ }
11405
+ }
11406
+ function join3(line, name, t) {
11407
+ clearTimeout(t.timer);
11408
+ t.joined = false;
11409
+ t.ref = frame(line, `realtime:${name}`, "phx_join", JOIN);
11410
+ t.timer = setTimeout(() => rejoin(line, name, t), JOIN_TIMEOUT_MS);
11411
+ }
11412
+ function rejoin(line, name, t) {
11413
+ clearTimeout(t.timer);
11414
+ t.joined = false;
11415
+ t.ref = void 0;
11416
+ if (!line.open || line.topics.get(name) !== t) return;
11417
+ t.timer = setTimeout(() => {
11418
+ if (line.open && line.topics.get(name) === t) join3(line, name, t);
11419
+ }, RETRY_MS[Math.min(t.attempt++, RETRY_MS.length - 1)]);
11420
+ }
11421
+ function joined(t) {
11422
+ clearTimeout(t.timer);
11423
+ t.joined = true;
11424
+ t.attempt = 0;
11425
+ for (const l of t.listeners) l.onJoined?.(++l.joins);
11373
11426
  }
11374
11427
  function connect(line) {
11375
11428
  const base = line.url.replace(/^http/, "ws").replace(/\/+$/, "");
11376
11429
  const ws = line.ws = new import_undici.WebSocket(`${base}/realtime/v1/websocket?apikey=${encodeURIComponent(line.anonKey)}&vsn=1.0.0`);
11430
+ let gone = false;
11431
+ const again = () => {
11432
+ if (gone) return;
11433
+ gone = true;
11434
+ stop(line);
11435
+ if (!line.topics.size) return;
11436
+ line.retry = setTimeout(() => connect(line), RETRY_MS[Math.min(line.attempt++, RETRY_MS.length - 1)]);
11437
+ };
11377
11438
  ws.addEventListener("open", () => {
11439
+ if (gone) return;
11378
11440
  line.open = true;
11379
11441
  line.attempt = 0;
11380
- for (const topic of line.topics.keys()) frame(line, `realtime:${topic}`, "phx_join", JOIN);
11381
- line.heart = setInterval(() => frame(line, "phoenix", "heartbeat", {}), HEARTBEAT_MS);
11442
+ for (const [name, t] of line.topics) join3(line, name, t);
11443
+ line.heart = setInterval(() => {
11444
+ if (line.beat) {
11445
+ try {
11446
+ ws.close();
11447
+ } catch {
11448
+ }
11449
+ again();
11450
+ return;
11451
+ }
11452
+ line.beat = frame(line, "phoenix", "heartbeat", {});
11453
+ }, HEARTBEAT_MS);
11382
11454
  });
11383
11455
  ws.addEventListener("message", (e) => {
11456
+ if (gone) return;
11384
11457
  let m;
11385
11458
  try {
11386
11459
  m = JSON.parse(String(e.data));
11387
11460
  } catch {
11388
11461
  return;
11389
11462
  }
11390
- if (m.event !== "broadcast" || !m.topic?.startsWith("realtime:")) return;
11391
- for (const l of line.topics.get(m.topic.slice("realtime:".length)) ?? []) {
11463
+ if (m.topic === "phoenix") {
11464
+ if (m.event === "phx_reply" && m.ref === line.beat) line.beat = void 0;
11465
+ return;
11466
+ }
11467
+ if (!m.topic?.startsWith("realtime:")) return;
11468
+ const name = m.topic.slice("realtime:".length);
11469
+ const t = line.topics.get(name);
11470
+ if (!t) return;
11471
+ if (m.event === "phx_reply") {
11472
+ if (!t.ref || m.ref !== t.ref) return;
11473
+ if (m.payload?.status === "ok") joined(t);
11474
+ else rejoin(line, name, t);
11475
+ return;
11476
+ }
11477
+ if (m.event === "phx_error" || m.event === "phx_close") {
11478
+ if (m.ref && m.ref !== t.ref) return;
11479
+ rejoin(line, name, t);
11480
+ return;
11481
+ }
11482
+ if (m.event !== "broadcast") return;
11483
+ for (const l of t.listeners) {
11392
11484
  if (m.payload?.event === l.event) l.onMessage(m.payload.payload);
11393
11485
  }
11394
11486
  });
11395
- let gone = false;
11396
- const again = () => {
11397
- if (gone) return;
11398
- gone = true;
11399
- stop(line);
11400
- if (!line.topics.size) return;
11401
- line.retry = setTimeout(() => connect(line), RETRY_MS[Math.min(line.attempt++, RETRY_MS.length - 1)]);
11402
- };
11403
11487
  ws.addEventListener("close", again);
11404
11488
  ws.addEventListener("error", again);
11405
11489
  }
@@ -11407,17 +11491,22 @@ function subscribeRealtime(args) {
11407
11491
  const key = `${args.url} ${args.anonKey}`;
11408
11492
  let line = lines.get(key);
11409
11493
  if (!line) lines.set(key, line = { url: args.url, anonKey: args.anonKey, open: false, attempt: 0, ref: 0, topics: /* @__PURE__ */ new Map() });
11410
- let heard = line.topics.get(args.topic);
11411
- if (!heard) {
11412
- line.topics.set(args.topic, heard = /* @__PURE__ */ new Set());
11413
- if (line.open) frame(line, `realtime:${args.topic}`, "phx_join", JOIN);
11414
- }
11415
- const me = { event: args.event, onMessage: args.onMessage };
11416
- heard.add(me);
11494
+ let t = line.topics.get(args.topic);
11495
+ if (!t) {
11496
+ line.topics.set(args.topic, t = { listeners: /* @__PURE__ */ new Set(), joined: false, attempt: 0 });
11497
+ if (line.open) join3(line, args.topic, t);
11498
+ }
11499
+ const topic = t;
11500
+ const me = { event: args.event, onMessage: args.onMessage, onJoined: args.onJoined, joins: 0 };
11501
+ topic.listeners.add(me);
11502
+ if (topic.joined && me.onJoined) queueMicrotask(() => {
11503
+ if (topic.joined && topic.listeners.has(me)) me.onJoined?.(++me.joins);
11504
+ });
11417
11505
  if (!line.ws) connect(line);
11418
11506
  return {
11419
11507
  close: () => {
11420
- if (!heard.delete(me) || heard.size) return;
11508
+ if (!topic.listeners.delete(me) || topic.listeners.size) return;
11509
+ clearTimeout(topic.timer);
11421
11510
  line.topics.delete(args.topic);
11422
11511
  if (line.topics.size) {
11423
11512
  if (line.open) frame(line, `realtime:${args.topic}`, "phx_leave", {});
@@ -11433,7 +11522,7 @@ function subscribeRealtime(args) {
11433
11522
  };
11434
11523
  }
11435
11524
  async function subscribeWake(onNudge, opts = {}) {
11436
- const { release = true, ...client } = opts;
11525
+ const { release = true, onJoined, ...client } = opts;
11437
11526
  const cfg = await registerDelivery("self_hosted", client);
11438
11527
  const { url, anonKey } = cfg.realtime;
11439
11528
  const channel = wakeChannel(cfg.tokenId);
@@ -11442,7 +11531,8 @@ async function subscribeWake(onNudge, opts = {}) {
11442
11531
  anonKey,
11443
11532
  topic: channel,
11444
11533
  event: WAKE_EVENT,
11445
- onMessage: (payload) => onNudge(payload ?? {})
11534
+ onMessage: (payload) => onNudge(payload ?? {}),
11535
+ onJoined
11446
11536
  });
11447
11537
  return {
11448
11538
  tokenId: cfg.tokenId,
@@ -11463,7 +11553,7 @@ import { homedir as homedir9, hostname } from "os";
11463
11553
  import { spawnSync } from "child_process";
11464
11554
  import { existsSync as existsSync2 } from "fs";
11465
11555
  import { homedir as homedir3 } from "os";
11466
- import { delimiter, dirname, join as join3 } from "path";
11556
+ import { delimiter, dirname, join as join4 } from "path";
11467
11557
  var CATALOG = [
11468
11558
  {
11469
11559
  name: "claude",
@@ -11499,10 +11589,10 @@ var CATALOG = [
11499
11589
  function probeDirs(home = homedir3()) {
11500
11590
  return [
11501
11591
  dirname(process.execPath),
11502
- join3(home, ".local", "bin"),
11592
+ join4(home, ".local", "bin"),
11503
11593
  "/usr/local/bin",
11504
11594
  "/opt/homebrew/bin",
11505
- join3(home, ".npm-global", "bin")
11595
+ join4(home, ".npm-global", "bin")
11506
11596
  ];
11507
11597
  }
11508
11598
  function which(bin) {
@@ -11510,7 +11600,7 @@ function which(bin) {
11510
11600
  const fromPath = hit.status === 0 ? hit.stdout.trim() : "";
11511
11601
  if (fromPath) return fromPath;
11512
11602
  for (const dir of probeDirs()) {
11513
- const candidate = join3(dir, bin);
11603
+ const candidate = join4(dir, bin);
11514
11604
  if (existsSync2(candidate)) return candidate;
11515
11605
  }
11516
11606
  return null;
@@ -11552,14 +11642,14 @@ function detectAll(deps = {}) {
11552
11642
  // src/host.ts
11553
11643
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
11554
11644
  import { homedir as homedir7 } from "os";
11555
- import { join as join7 } from "path";
11645
+ import { join as join8 } from "path";
11556
11646
 
11557
11647
  // src/workspaces.ts
11558
11648
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
11559
- import { dirname as dirname2, join as join4, resolve } from "path";
11649
+ import { dirname as dirname2, join as join5, resolve } from "path";
11560
11650
  import { homedir as homedir4 } from "os";
11561
11651
  function workspacesFile() {
11562
- return join4(homedir4(), ".paigy", "workspaces.json");
11652
+ return join5(homedir4(), ".paigy", "workspaces.json");
11563
11653
  }
11564
11654
  function listWorkspaces(deps) {
11565
11655
  try {
@@ -11600,7 +11690,7 @@ function resolveWakeDir(pinned, deps) {
11600
11690
  // src/owner.ts
11601
11691
  import { closeSync as closeSync2, openSync as openSync2, readSync, readdirSync } from "fs";
11602
11692
  import { homedir as homedir5 } from "os";
11603
- import { join as join5 } from "path";
11693
+ import { join as join6 } from "path";
11604
11694
  var HEAD_BYTES = 64e3;
11605
11695
  function readHead(file) {
11606
11696
  const fd = openSync2(file, "r");
@@ -11616,7 +11706,7 @@ function ownerSession(slot, deps = {}) {
11616
11706
  const m = /^session:([0-9a-f]{8})$/.exec(slot);
11617
11707
  if (!m) return null;
11618
11708
  const prefix = m[1];
11619
- const root = deps.projectsDir ?? join5(homedir5(), ".claude", "projects");
11709
+ const root = deps.projectsDir ?? join6(homedir5(), ".claude", "projects");
11620
11710
  const list = deps.list ?? ((dir) => readdirSync(dir));
11621
11711
  const head = deps.head ?? readHead;
11622
11712
  let folders;
@@ -11628,7 +11718,7 @@ function ownerSession(slot, deps = {}) {
11628
11718
  for (const folder of folders) {
11629
11719
  let files;
11630
11720
  try {
11631
- files = list(join5(root, folder));
11721
+ files = list(join6(root, folder));
11632
11722
  } catch {
11633
11723
  continue;
11634
11724
  }
@@ -11636,7 +11726,7 @@ function ownerSession(slot, deps = {}) {
11636
11726
  if (!file) continue;
11637
11727
  let text;
11638
11728
  try {
11639
- text = head(join5(root, folder, file));
11729
+ text = head(join6(root, folder, file));
11640
11730
  } catch {
11641
11731
  continue;
11642
11732
  }
@@ -12991,7 +13081,7 @@ var AskInputSchema2 = external_exports.object({
12991
13081
  parentId: external_exports.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."),
12992
13082
  repo: external_exports.string().optional().describe("Optional repository context."),
12993
13083
  ask: external_exports.string().trim().min(1).max(1e4).describe(
12994
- "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."
13084
+ "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."
12995
13085
  ),
12996
13086
  options: external_exports.array(OptionInputSchema2).min(2).max(6).optional()
12997
13087
  }).strict();
@@ -13002,7 +13092,7 @@ var StartContactSchema2 = external_exports.object({
13002
13092
  }).strict();
13003
13093
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
13004
13094
  var CONTACT_SCHEMA2 = { type: "object", ...mcpInputSchema2(ContactSchema2) };
13005
- var CONTACT_DESCRIPTION2 = "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.";
13095
+ var CONTACT_DESCRIPTION2 = "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).";
13006
13096
  var CreateGoalSchema2 = external_exports.object({
13007
13097
  outcome: external_exports.string().trim().min(1).max(1e4),
13008
13098
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -13049,9 +13139,9 @@ var UpdateGoalToolSchema2 = UpdateGoalSchema2.omit({ operationId: true }).extend
13049
13139
  var ClaimGoalSchema2 = external_exports.object({ goalId: external_exports.string().uuid().optional() }).strict();
13050
13140
  var GetGoalSchema2 = external_exports.object({ goalId: external_exports.string().uuid() }).strict();
13051
13141
  var GET_GOAL_DESCRIPTION2 = "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.";
13052
- var UPDATE_GOAL_DESCRIPTION2 = "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.";
13053
- var CLAIM_GOAL_DESCRIPTION2 = "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.";
13054
- var CHECK_REPLIES_DESCRIPTION2 = "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.";
13142
+ var UPDATE_GOAL_DESCRIPTION2 = `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.`;
13143
+ var CLAIM_GOAL_DESCRIPTION2 = "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.";
13144
+ var CHECK_REPLIES_DESCRIPTION2 = "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.";
13055
13145
  var CheckRepliesSchema2 = external_exports.object({}).strict();
13056
13146
  var AGENT_TOOLS2 = [
13057
13147
  { name: "contact", description: CONTACT_DESCRIPTION2, inputSchema: CONTACT_SCHEMA2 },
@@ -13396,9 +13486,9 @@ var AgendaTurnSchema2 = external_exports.object({
13396
13486
  /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
13397
13487
  * the planner declares duplicates instead of asking them twice. */
13398
13488
  coveredIds: external_exports.array(external_exports.string()).optional(),
13399
- /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
13400
- * line went out on 2026-07-28 and the caller could not answer it at all. */
13401
- info: external_exports.array(external_exports.string().min(1)).max(3).default([]),
13489
+ /** The spoken sentences of the turn, in order. No count: how long a turn is is the brain's call
13490
+ * (owner, 2026-09-25), and a count here refused whole plans. */
13491
+ info: external_exports.array(external_exports.string().min(1)).default([]),
13402
13492
  question: external_exports.string().min(1).nullable(),
13403
13493
  /** True on the one turn carrying the agent's own declared question. */
13404
13494
  asks: external_exports.boolean().optional(),
@@ -13433,32 +13523,6 @@ var AgendaTurnSchema2 = external_exports.object({
13433
13523
  var CLAIM_STALE_MS2 = 30 * 6e4;
13434
13524
  var InboxItemSchema2 = external_exports.object({
13435
13525
  id: external_exports.string(),
13436
- /** The conversation thread + connection this item lives on. Present on the replied
13437
- * detail — they power History's "Continue" / "New session from this" (#57/#251). */
13438
- parentId: external_exports.string().optional(),
13439
- /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
13440
- * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
13441
- * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
13442
- * accumulates every message an agent ever sent, so grouping by it renders a day of
13443
- * unrelated updates as a single "12-part request". Absent on rows written before the
13444
- * `asks` table, and on anything that never went through `notify` — both fall back to the
13445
- * thread, which is what the client did for all rows until now. */
13446
- askId: external_exports.string().optional(),
13447
- /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
13448
- * shares one `created_at` to the microsecond, so without it the author's order is
13449
- * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
13450
- * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
13451
- * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
13452
- seq: external_exports.number().int().optional(),
13453
- /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
13454
- * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
13455
- * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
13456
- * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
13457
- * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
13458
- * server that can still see every row states it. Absent on any row with no `askId`: a
13459
- * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
13460
- * the one part of its own arrival a single row cannot answer. */
13461
- units: external_exports.number().int().positive().optional(),
13462
13526
  tokenId: external_exports.string().optional(),
13463
13527
  status: NotifyStatusSchema2,
13464
13528
  context: ContextSchema2,
@@ -13503,7 +13567,12 @@ var InboxItemSchema2 = external_exports.object({
13503
13567
  entryId: external_exports.string(),
13504
13568
  title: external_exports.string(),
13505
13569
  state: external_exports.enum(["open", "answered"]),
13506
- answer: external_exports.string().nullable()
13570
+ answer: external_exports.string().nullable(),
13571
+ /** WHO ASKED IT (owner, 2026-09-23, Goal a345e906: each agenda row wears its agent's face) — the
13572
+ * request Entry's author, as the same three facts the item's own `tokenId`/`name`/`voice`
13573
+ * carry for the call's one agent, so the phone draws it with the same seed. Absent when the
13574
+ * author is not an agent this account holds (unpaired since, or a person). */
13575
+ agent: external_exports.object({ tokenId: external_exports.string(), name: external_exports.string(), voice: VoiceKeySchema2.optional() }).optional()
13507
13576
  })).optional(),
13508
13577
  visuals: external_exports.array(VisualSchema2).optional(),
13509
13578
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -13521,10 +13590,20 @@ var InboxItemSchema2 = external_exports.object({
13521
13590
  * agent (held in the inbox until the agent replies on the thread). */
13522
13591
  turn: external_exports.enum(["you", "agent"]).default("you"),
13523
13592
  /** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
13524
- * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
13525
- * client may still flag a stall by age. Drives the inbox error badge + Retry. */
13593
+ * the agent (provider-agnostic; set server-side). Absent = no hard error. Drives the inbox
13594
+ * error badge + Retry. */
13526
13595
  error: external_exports.string().optional(),
13596
+ /** WHEN THIS AGENT WORK WENT QUIET (turn="agent"), by the one rule (`coldSince`: three days
13597
+ * with nothing said), or absent while it is not stalled. The inbox's stalled badge reads
13598
+ * this and nothing else (2026-09-23: a 3-minute age rule badged every live Goal stalled,
13599
+ * and "dismiss the stalled ones" cancelled 37 pieces of live work). */
13600
+ cold: external_exports.string().datetime().optional(),
13527
13601
  clarifies: external_exports.string().optional(),
13602
+ /** THIS CARD'S QUESTION IS ON A LIVE CALL (owner, 2026-09-24: "Mark it while the call is
13603
+ * live"). Present only while an open Call Delivery carries the card's request Entry — read
13604
+ * off the same open list the card came from, so it clears when the Call does. A card is the
13605
+ * backup for a call not taken; while the call has it, the call is where it is answered. */
13606
+ onCall: external_exports.literal(true).optional(),
13528
13607
  /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
13529
13608
  * declined, and what the ladder will do next — read off the cron's own row, never computed
13530
13609
  * on the phone. Present only while a `declined` receipt stands on the card's last Call.
@@ -13573,7 +13652,10 @@ var InboxItemSchema2 = external_exports.object({
13573
13652
  goalIds: external_exports.array(external_exports.string()),
13574
13653
  decisionNeedId: external_exports.string().optional(),
13575
13654
  sealed: external_exports.boolean(),
13576
- goalState: external_exports.string().optional()
13655
+ goalState: external_exports.string().optional(),
13656
+ /** THAT GOAL'S NAME (#2416) — what Activity's row is headed by, since a row there is one Goal
13657
+ * and the cards it holds sit behind it. Stamped by the same read as `goalState`. */
13658
+ goalTitle: external_exports.string().optional()
13577
13659
  }).optional()
13578
13660
  });
13579
13661
  var APNS_TOKEN_RE2 = /^[0-9a-fA-F]{64}$/;
@@ -13672,7 +13754,6 @@ var UserSettingsSchema2 = external_exports.object({
13672
13754
  });
13673
13755
  var HistoryItemSchema2 = external_exports.object({
13674
13756
  id: external_exports.string(),
13675
- parentId: external_exports.string(),
13676
13757
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
13677
13758
  initiator: external_exports.enum(["user", "agent"]),
13678
13759
  title: external_exports.string(),
@@ -13831,6 +13912,24 @@ var QueueQuestionSchema2 = external_exports.object({
13831
13912
  * gate at all. */
13832
13913
  blocked: external_exports.boolean().default(false)
13833
13914
  });
13915
+ var QueueReplySchema2 = external_exports.object({
13916
+ /** The card this note was (`deliveryId:requestEntryId`, minted by the server like every card
13917
+ * id) — so the phone can tell a reply it just sent from one the queue already carries, and the
13918
+ * walk can name it in its zoom. */
13919
+ id: external_exports.string(),
13920
+ /** The Goal the note is on. */
13921
+ goalId: external_exports.string(),
13922
+ /** What the note said. */
13923
+ note: external_exports.string(),
13924
+ /** Where it was carried — where a second reply goes (`POST /api/entries`, #2252). */
13925
+ deliveryId: external_exports.string(),
13926
+ requestEntryId: external_exports.string(),
13927
+ askedAt: external_exports.string(),
13928
+ /** When the person last replied — the window's start. */
13929
+ repliedAt: external_exports.string(),
13930
+ /** The person's latest words about it; null when there is nothing readable in them. */
13931
+ reply: external_exports.string().nullable()
13932
+ });
13834
13933
  var QueueItemSchema2 = external_exports.object({
13835
13934
  id: external_exports.string(),
13836
13935
  /** One-line headline — the first sentence of the outcome. */
@@ -13858,6 +13957,10 @@ var QueueItemSchema2 = external_exports.object({
13858
13957
  blocked: external_exports.boolean().default(false),
13859
13958
  /** Every decision need on it, open or settled — the page decides which to show. */
13860
13959
  questions: external_exports.array(QueueQuestionSchema2).default([]),
13960
+ /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
13961
+ * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
13962
+ * not spell an empty list. */
13963
+ replies: external_exports.array(QueueReplySchema2).optional(),
13861
13964
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
13862
13965
  repo: external_exports.string().nullable().optional(),
13863
13966
  createdAt: external_exports.string(),
@@ -14529,6 +14632,7 @@ async function ask(state, rail, input) {
14529
14632
  waiting: "hard",
14530
14633
  channel: input.call ? "call" : "notification"
14531
14634
  });
14635
+ if ("recorded" in sent) return null;
14532
14636
  const entryId = sent.requestEntryIds[0];
14533
14637
  if (!entryId) return null;
14534
14638
  (state.seen ??= /* @__PURE__ */ new Set()).add(entryId);
@@ -14705,7 +14809,7 @@ function runHarness(opts) {
14705
14809
  // src/update.ts
14706
14810
  import { execFile } from "child_process";
14707
14811
  import { createRequire } from "module";
14708
- import { dirname as dirname3, join as join6 } from "path";
14812
+ import { dirname as dirname3, join as join7 } from "path";
14709
14813
  import { promisify } from "util";
14710
14814
  var SERVICE_LABEL = "ai.paigy.harness";
14711
14815
  var VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -14732,7 +14836,7 @@ async function latestVersion() {
14732
14836
  }
14733
14837
  async function install(version) {
14734
14838
  const bin = dirname3(process.execPath);
14735
- await promisify(execFile)(join6(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
14839
+ await promisify(execFile)(join7(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
14736
14840
  env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, npm_config_update_notifier: "false" },
14737
14841
  timeout: 5 * 6e4
14738
14842
  });
@@ -14783,7 +14887,7 @@ function selfUpdater(deps) {
14783
14887
  }
14784
14888
 
14785
14889
  // src/host.ts
14786
- var HOST_FILE = join7(homedir7(), ".paigy", "host.json");
14890
+ var HOST_FILE = join8(homedir7(), ".paigy", "host.json");
14787
14891
  function readHostState() {
14788
14892
  try {
14789
14893
  const s = JSON.parse(readFileSync4(HOST_FILE, "utf8"));
@@ -15646,7 +15750,7 @@ var win = null;
15646
15750
  function log(message) {
15647
15751
  win?.webContents.send("paigy:log", message);
15648
15752
  }
15649
- var ICON = join8(here, "..", "assets", "icon.png");
15753
+ var ICON = join9(here, "..", "assets", "icon.png");
15650
15754
  function createWindow() {
15651
15755
  win = new BrowserWindow({
15652
15756
  // Sized for the working log, which is now the window's centerpiece; the layout is a
@@ -15658,9 +15762,9 @@ function createWindow() {
15658
15762
  minHeight: 520,
15659
15763
  icon: ICON,
15660
15764
  // Windows/Linux window icon; macOS uses the dock icon below
15661
- webPreferences: { preload: join8(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
15765
+ webPreferences: { preload: join9(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
15662
15766
  });
15663
- void win.loadFile(join8(here, "..", "renderer", "index.html"));
15767
+ void win.loadFile(join9(here, "..", "renderer", "index.html"));
15664
15768
  }
15665
15769
  var host = null;
15666
15770
  app.whenReady().then(() => {
@@ -15729,7 +15833,7 @@ ipcMain.handle("paigy:agent", async (_e, name) => {
15729
15833
  });
15730
15834
  ipcMain.handle("paigy:agent-log", (_e, name) => {
15731
15835
  try {
15732
- const all = readFileSync5(join8(homedir9(), ".paigy", "host.log"), "utf8").split("\n");
15836
+ const all = readFileSync5(join9(homedir9(), ".paigy", "host.log"), "utf8").split("\n");
15733
15837
  const mine = all.filter((l) => l.includes(`[${name}]`) || /[▶✖◉⚠]/.test(l) && l.includes(name));
15734
15838
  return mine.slice(-500).join("\n");
15735
15839
  } catch {