@paigy/harness 0.3.15 → 0.3.17

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 +302 -137
  2. package/dist/main.js +297 -136
  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,18 @@ 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(),
10453
+ /** ITS OPTIONS, WHEN THERE IS SOMETHING TO SEE (owner, 2026-09-25: "Yes, add it"): the options
10454
+ * its need offers, exactly as its own card carries them, present only when one of them has a
10455
+ * preview (`html` or `image`). The call screen opens them from the agenda row, so a preview is
10456
+ * never re-sent as a second card to be seen mid-call. Words-only options are absent — the bot
10457
+ * says those, and the list stays small (an `html` is up to 16 KB). */
10458
+ options: external_exports.array(OptionSchema).optional()
10474
10459
  })).optional(),
10475
10460
  visuals: external_exports.array(VisualSchema).optional(),
10476
10461
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -10488,10 +10473,20 @@ var InboxItemSchema = external_exports.object({
10488
10473
  * agent (held in the inbox until the agent replies on the thread). */
10489
10474
  turn: external_exports.enum(["you", "agent"]).default("you"),
10490
10475
  /** 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. */
10476
+ * the agent (provider-agnostic; set server-side). Absent = no hard error. Drives the inbox
10477
+ * error badge + Retry. */
10493
10478
  error: external_exports.string().optional(),
10479
+ /** WHEN THIS AGENT WORK WENT QUIET (turn="agent"), by the one rule (`coldSince`: three days
10480
+ * with nothing said), or absent while it is not stalled. The inbox's stalled badge reads
10481
+ * this and nothing else (2026-09-23: a 3-minute age rule badged every live Goal stalled,
10482
+ * and "dismiss the stalled ones" cancelled 37 pieces of live work). */
10483
+ cold: external_exports.string().datetime().optional(),
10494
10484
  clarifies: external_exports.string().optional(),
10485
+ /** THIS CARD'S QUESTION IS ON A LIVE CALL (owner, 2026-09-24: "Mark it while the call is
10486
+ * live"). Present only while an open Call Delivery carries the card's request Entry — read
10487
+ * off the same open list the card came from, so it clears when the Call does. A card is the
10488
+ * backup for a call not taken; while the call has it, the call is where it is answered. */
10489
+ onCall: external_exports.literal(true).optional(),
10495
10490
  /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
10496
10491
  * declined, and what the ladder will do next — read off the cron's own row, never computed
10497
10492
  * on the phone. Present only while a `declined` receipt stands on the card's last Call.
@@ -10540,7 +10535,10 @@ var InboxItemSchema = external_exports.object({
10540
10535
  goalIds: external_exports.array(external_exports.string()),
10541
10536
  decisionNeedId: external_exports.string().optional(),
10542
10537
  sealed: external_exports.boolean(),
10543
- goalState: external_exports.string().optional()
10538
+ goalState: external_exports.string().optional(),
10539
+ /** THAT GOAL'S NAME (#2416) — what Activity's row is headed by, since a row there is one Goal
10540
+ * and the cards it holds sit behind it. Stamped by the same read as `goalState`. */
10541
+ goalTitle: external_exports.string().optional()
10544
10542
  }).optional()
10545
10543
  });
10546
10544
  var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
@@ -10639,7 +10637,6 @@ var UserSettingsSchema = external_exports.object({
10639
10637
  });
10640
10638
  var HistoryItemSchema = external_exports.object({
10641
10639
  id: external_exports.string(),
10642
- parentId: external_exports.string(),
10643
10640
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
10644
10641
  initiator: external_exports.enum(["user", "agent"]),
10645
10642
  title: external_exports.string(),
@@ -10798,6 +10795,24 @@ var QueueQuestionSchema = external_exports.object({
10798
10795
  * gate at all. */
10799
10796
  blocked: external_exports.boolean().default(false)
10800
10797
  });
10798
+ var QueueReplySchema = external_exports.object({
10799
+ /** The card this note was (`deliveryId:requestEntryId`, minted by the server like every card
10800
+ * id) — so the phone can tell a reply it just sent from one the queue already carries, and the
10801
+ * walk can name it in its zoom. */
10802
+ id: external_exports.string(),
10803
+ /** The Goal the note is on. */
10804
+ goalId: external_exports.string(),
10805
+ /** What the note said. */
10806
+ note: external_exports.string(),
10807
+ /** Where it was carried — where a second reply goes (`POST /api/entries`, #2252). */
10808
+ deliveryId: external_exports.string(),
10809
+ requestEntryId: external_exports.string(),
10810
+ askedAt: external_exports.string(),
10811
+ /** When the person last replied — the window's start. */
10812
+ repliedAt: external_exports.string(),
10813
+ /** The person's latest words about it; null when there is nothing readable in them. */
10814
+ reply: external_exports.string().nullable()
10815
+ });
10801
10816
  var QueueItemSchema = external_exports.object({
10802
10817
  id: external_exports.string(),
10803
10818
  /** One-line headline — the first sentence of the outcome. */
@@ -10825,6 +10840,10 @@ var QueueItemSchema = external_exports.object({
10825
10840
  blocked: external_exports.boolean().default(false),
10826
10841
  /** Every decision need on it, open or settled — the page decides which to show. */
10827
10842
  questions: external_exports.array(QueueQuestionSchema).default([]),
10843
+ /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
10844
+ * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
10845
+ * not spell an empty list. */
10846
+ replies: external_exports.array(QueueReplySchema).optional(),
10828
10847
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
10829
10848
  repo: external_exports.string().nullable().optional(),
10830
10849
  createdAt: external_exports.string(),
@@ -11285,6 +11304,7 @@ function currentRepo(cwd = process.cwd()) {
11285
11304
  }
11286
11305
  return asked;
11287
11306
  }
11307
+ 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
11308
  async function contact(input, opts = {}) {
11289
11309
  const parsed = ContactSchema.parse(input);
11290
11310
  opts.signal?.throwIfAborted();
@@ -11292,6 +11312,7 @@ async function contact(input, opts = {}) {
11292
11312
  const headers = { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token) ?? ""}`, "x-paigy-model": "goal-entry-v1" };
11293
11313
  let deliveryId;
11294
11314
  let deliveries = [];
11315
+ let recorded = [];
11295
11316
  if ("deliveryId" in parsed) {
11296
11317
  deliveryId = parsed.deliveryId;
11297
11318
  } else {
@@ -11320,16 +11341,24 @@ async function contact(input, opts = {}) {
11320
11341
  if (!res.ok) await fail("batch contact", res);
11321
11342
  const receipt = await res.json();
11322
11343
  deliveries = receipt.deliveries ?? [];
11344
+ recorded = receipt.recorded ?? [];
11345
+ if (deliveries.length === 0 && recorded.length) return { sent: false, recorded, message: NOT_SENT };
11323
11346
  if (deliveries.length === 0) throw new Error("Goal contact returned no Delivery identities");
11324
11347
  deliveryId = deliveries[0]?.deliveryId ?? "";
11325
11348
  if (!deliveryId) throw new Error("Goal contact returned no valid Delivery identity");
11326
11349
  }
11327
- const all = deliveries.filter((d) => !!d.deliveryId && !!d.goalId);
11350
+ const all = deliveries.filter((d) => !!d.deliveryId && !!d.goalId).map(({ id, deliveryId: deliveryId2, goalId }) => ({ ...id ? { id } : {}, deliveryId: deliveryId2, goalId }));
11351
+ const joinedCard = deliveries[0]?.joinedCard === true;
11328
11352
  const read = async (signal2) => {
11329
11353
  const res = ensureAuthed(await send22(`${BACKEND_URL}/api/deliveries/${encodeURIComponent(deliveryId)}`, { headers, signal: signal2 }));
11330
11354
  if (!res.ok) await fail("read_delivery", res);
11331
11355
  const one = await res.json();
11332
- return all.length > 1 ? { ...one, deliveries: all } : one;
11356
+ return {
11357
+ ...one,
11358
+ ...all.length > 1 ? { deliveries: all } : {},
11359
+ ...recorded.length ? { recorded, notSent: NOT_SENT } : {},
11360
+ ...joinedCard ? { joinedCard: true } : {}
11361
+ };
11333
11362
  };
11334
11363
  const settled = (d) => d.kind === "notification" || d.state === "closed" || d.answers.length > 0 || d.entries.some((e) => e.kind === "contribution");
11335
11364
  if (opts.waits === false) return read(opts.signal);
@@ -11359,47 +11388,108 @@ async function checkReplies(opts = {}) {
11359
11388
  var HEARTBEAT_MS = 25e3;
11360
11389
  var RETRY_MS = [1e3, 2e3, 5e3, 1e4, 3e4];
11361
11390
  var JOIN = { config: { broadcast: { self: false } } };
11391
+ var JOIN_TIMEOUT_MS = 1e4;
11362
11392
  var lines = /* @__PURE__ */ new Map();
11363
11393
  function frame(line, topic, event, payload) {
11394
+ const ref = String(++line.ref);
11364
11395
  try {
11365
- line.ws?.send(JSON.stringify({ topic, event, payload, ref: String(++line.ref) }));
11396
+ line.ws?.send(JSON.stringify({ topic, event, payload, ref }));
11366
11397
  } catch {
11367
11398
  }
11399
+ return ref;
11368
11400
  }
11369
11401
  function stop(line) {
11370
11402
  line.open = false;
11403
+ line.beat = void 0;
11371
11404
  clearInterval(line.heart);
11372
11405
  clearTimeout(line.retry);
11406
+ for (const t of line.topics.values()) {
11407
+ clearTimeout(t.timer);
11408
+ t.joined = false;
11409
+ t.ref = void 0;
11410
+ }
11411
+ }
11412
+ function join3(line, name, t) {
11413
+ clearTimeout(t.timer);
11414
+ t.joined = false;
11415
+ t.ref = frame(line, `realtime:${name}`, "phx_join", JOIN);
11416
+ t.timer = setTimeout(() => rejoin(line, name, t), JOIN_TIMEOUT_MS);
11417
+ }
11418
+ function rejoin(line, name, t) {
11419
+ clearTimeout(t.timer);
11420
+ t.joined = false;
11421
+ t.ref = void 0;
11422
+ if (!line.open || line.topics.get(name) !== t) return;
11423
+ t.timer = setTimeout(() => {
11424
+ if (line.open && line.topics.get(name) === t) join3(line, name, t);
11425
+ }, RETRY_MS[Math.min(t.attempt++, RETRY_MS.length - 1)]);
11426
+ }
11427
+ function joined(t) {
11428
+ clearTimeout(t.timer);
11429
+ t.joined = true;
11430
+ t.attempt = 0;
11431
+ for (const l of t.listeners) l.onJoined?.(++l.joins);
11373
11432
  }
11374
11433
  function connect(line) {
11375
11434
  const base = line.url.replace(/^http/, "ws").replace(/\/+$/, "");
11376
11435
  const ws = line.ws = new import_undici.WebSocket(`${base}/realtime/v1/websocket?apikey=${encodeURIComponent(line.anonKey)}&vsn=1.0.0`);
11436
+ let gone = false;
11437
+ const again = () => {
11438
+ if (gone) return;
11439
+ gone = true;
11440
+ stop(line);
11441
+ if (!line.topics.size) return;
11442
+ line.retry = setTimeout(() => connect(line), RETRY_MS[Math.min(line.attempt++, RETRY_MS.length - 1)]);
11443
+ };
11377
11444
  ws.addEventListener("open", () => {
11445
+ if (gone) return;
11378
11446
  line.open = true;
11379
11447
  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);
11448
+ for (const [name, t] of line.topics) join3(line, name, t);
11449
+ line.heart = setInterval(() => {
11450
+ if (line.beat) {
11451
+ try {
11452
+ ws.close();
11453
+ } catch {
11454
+ }
11455
+ again();
11456
+ return;
11457
+ }
11458
+ line.beat = frame(line, "phoenix", "heartbeat", {});
11459
+ }, HEARTBEAT_MS);
11382
11460
  });
11383
11461
  ws.addEventListener("message", (e) => {
11462
+ if (gone) return;
11384
11463
  let m;
11385
11464
  try {
11386
11465
  m = JSON.parse(String(e.data));
11387
11466
  } catch {
11388
11467
  return;
11389
11468
  }
11390
- if (m.event !== "broadcast" || !m.topic?.startsWith("realtime:")) return;
11391
- for (const l of line.topics.get(m.topic.slice("realtime:".length)) ?? []) {
11469
+ if (m.topic === "phoenix") {
11470
+ if (m.event === "phx_reply" && m.ref === line.beat) line.beat = void 0;
11471
+ return;
11472
+ }
11473
+ if (!m.topic?.startsWith("realtime:")) return;
11474
+ const name = m.topic.slice("realtime:".length);
11475
+ const t = line.topics.get(name);
11476
+ if (!t) return;
11477
+ if (m.event === "phx_reply") {
11478
+ if (!t.ref || m.ref !== t.ref) return;
11479
+ if (m.payload?.status === "ok") joined(t);
11480
+ else rejoin(line, name, t);
11481
+ return;
11482
+ }
11483
+ if (m.event === "phx_error" || m.event === "phx_close") {
11484
+ if (m.ref && m.ref !== t.ref) return;
11485
+ rejoin(line, name, t);
11486
+ return;
11487
+ }
11488
+ if (m.event !== "broadcast") return;
11489
+ for (const l of t.listeners) {
11392
11490
  if (m.payload?.event === l.event) l.onMessage(m.payload.payload);
11393
11491
  }
11394
11492
  });
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
11493
  ws.addEventListener("close", again);
11404
11494
  ws.addEventListener("error", again);
11405
11495
  }
@@ -11407,17 +11497,22 @@ function subscribeRealtime(args) {
11407
11497
  const key = `${args.url} ${args.anonKey}`;
11408
11498
  let line = lines.get(key);
11409
11499
  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);
11500
+ let t = line.topics.get(args.topic);
11501
+ if (!t) {
11502
+ line.topics.set(args.topic, t = { listeners: /* @__PURE__ */ new Set(), joined: false, attempt: 0 });
11503
+ if (line.open) join3(line, args.topic, t);
11504
+ }
11505
+ const topic = t;
11506
+ const me = { event: args.event, onMessage: args.onMessage, onJoined: args.onJoined, joins: 0 };
11507
+ topic.listeners.add(me);
11508
+ if (topic.joined && me.onJoined) queueMicrotask(() => {
11509
+ if (topic.joined && topic.listeners.has(me)) me.onJoined?.(++me.joins);
11510
+ });
11417
11511
  if (!line.ws) connect(line);
11418
11512
  return {
11419
11513
  close: () => {
11420
- if (!heard.delete(me) || heard.size) return;
11514
+ if (!topic.listeners.delete(me) || topic.listeners.size) return;
11515
+ clearTimeout(topic.timer);
11421
11516
  line.topics.delete(args.topic);
11422
11517
  if (line.topics.size) {
11423
11518
  if (line.open) frame(line, `realtime:${args.topic}`, "phx_leave", {});
@@ -11433,7 +11528,7 @@ function subscribeRealtime(args) {
11433
11528
  };
11434
11529
  }
11435
11530
  async function subscribeWake(onNudge, opts = {}) {
11436
- const { release = true, ...client } = opts;
11531
+ const { release = true, onJoined, ...client } = opts;
11437
11532
  const cfg = await registerDelivery("self_hosted", client);
11438
11533
  const { url, anonKey } = cfg.realtime;
11439
11534
  const channel = wakeChannel(cfg.tokenId);
@@ -11442,7 +11537,8 @@ async function subscribeWake(onNudge, opts = {}) {
11442
11537
  anonKey,
11443
11538
  topic: channel,
11444
11539
  event: WAKE_EVENT,
11445
- onMessage: (payload) => onNudge(payload ?? {})
11540
+ onMessage: (payload) => onNudge(payload ?? {}),
11541
+ onJoined
11446
11542
  });
11447
11543
  return {
11448
11544
  tokenId: cfg.tokenId,
@@ -11463,7 +11559,7 @@ import { homedir as homedir9, hostname } from "os";
11463
11559
  import { spawnSync } from "child_process";
11464
11560
  import { existsSync as existsSync2 } from "fs";
11465
11561
  import { homedir as homedir3 } from "os";
11466
- import { delimiter, dirname, join as join3 } from "path";
11562
+ import { delimiter, dirname, join as join4 } from "path";
11467
11563
  var CATALOG = [
11468
11564
  {
11469
11565
  name: "claude",
@@ -11499,10 +11595,10 @@ var CATALOG = [
11499
11595
  function probeDirs(home = homedir3()) {
11500
11596
  return [
11501
11597
  dirname(process.execPath),
11502
- join3(home, ".local", "bin"),
11598
+ join4(home, ".local", "bin"),
11503
11599
  "/usr/local/bin",
11504
11600
  "/opt/homebrew/bin",
11505
- join3(home, ".npm-global", "bin")
11601
+ join4(home, ".npm-global", "bin")
11506
11602
  ];
11507
11603
  }
11508
11604
  function which(bin) {
@@ -11510,7 +11606,7 @@ function which(bin) {
11510
11606
  const fromPath = hit.status === 0 ? hit.stdout.trim() : "";
11511
11607
  if (fromPath) return fromPath;
11512
11608
  for (const dir of probeDirs()) {
11513
- const candidate = join3(dir, bin);
11609
+ const candidate = join4(dir, bin);
11514
11610
  if (existsSync2(candidate)) return candidate;
11515
11611
  }
11516
11612
  return null;
@@ -11552,14 +11648,14 @@ function detectAll(deps = {}) {
11552
11648
  // src/host.ts
11553
11649
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
11554
11650
  import { homedir as homedir7 } from "os";
11555
- import { join as join7 } from "path";
11651
+ import { join as join8 } from "path";
11556
11652
 
11557
11653
  // src/workspaces.ts
11558
11654
  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";
11655
+ import { dirname as dirname2, join as join5, resolve } from "path";
11560
11656
  import { homedir as homedir4 } from "os";
11561
11657
  function workspacesFile() {
11562
- return join4(homedir4(), ".paigy", "workspaces.json");
11658
+ return join5(homedir4(), ".paigy", "workspaces.json");
11563
11659
  }
11564
11660
  function listWorkspaces(deps) {
11565
11661
  try {
@@ -11600,7 +11696,7 @@ function resolveWakeDir(pinned, deps) {
11600
11696
  // src/owner.ts
11601
11697
  import { closeSync as closeSync2, openSync as openSync2, readSync, readdirSync } from "fs";
11602
11698
  import { homedir as homedir5 } from "os";
11603
- import { join as join5 } from "path";
11699
+ import { join as join6 } from "path";
11604
11700
  var HEAD_BYTES = 64e3;
11605
11701
  function readHead(file) {
11606
11702
  const fd = openSync2(file, "r");
@@ -11616,7 +11712,7 @@ function ownerSession(slot, deps = {}) {
11616
11712
  const m = /^session:([0-9a-f]{8})$/.exec(slot);
11617
11713
  if (!m) return null;
11618
11714
  const prefix = m[1];
11619
- const root = deps.projectsDir ?? join5(homedir5(), ".claude", "projects");
11715
+ const root = deps.projectsDir ?? join6(homedir5(), ".claude", "projects");
11620
11716
  const list = deps.list ?? ((dir) => readdirSync(dir));
11621
11717
  const head = deps.head ?? readHead;
11622
11718
  let folders;
@@ -11628,7 +11724,7 @@ function ownerSession(slot, deps = {}) {
11628
11724
  for (const folder of folders) {
11629
11725
  let files;
11630
11726
  try {
11631
- files = list(join5(root, folder));
11727
+ files = list(join6(root, folder));
11632
11728
  } catch {
11633
11729
  continue;
11634
11730
  }
@@ -11636,7 +11732,7 @@ function ownerSession(slot, deps = {}) {
11636
11732
  if (!file) continue;
11637
11733
  let text;
11638
11734
  try {
11639
- text = head(join5(root, folder, file));
11735
+ text = head(join6(root, folder, file));
11640
11736
  } catch {
11641
11737
  continue;
11642
11738
  }
@@ -12991,7 +13087,7 @@ var AskInputSchema2 = external_exports.object({
12991
13087
  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
13088
  repo: external_exports.string().optional().describe("Optional repository context."),
12993
13089
  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."
13090
+ "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
13091
  ),
12996
13092
  options: external_exports.array(OptionInputSchema2).min(2).max(6).optional()
12997
13093
  }).strict();
@@ -13002,7 +13098,7 @@ var StartContactSchema2 = external_exports.object({
13002
13098
  }).strict();
13003
13099
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
13004
13100
  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.";
13101
+ 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
13102
  var CreateGoalSchema2 = external_exports.object({
13007
13103
  outcome: external_exports.string().trim().min(1).max(1e4),
13008
13104
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -13049,9 +13145,9 @@ var UpdateGoalToolSchema2 = UpdateGoalSchema2.omit({ operationId: true }).extend
13049
13145
  var ClaimGoalSchema2 = external_exports.object({ goalId: external_exports.string().uuid().optional() }).strict();
13050
13146
  var GetGoalSchema2 = external_exports.object({ goalId: external_exports.string().uuid() }).strict();
13051
13147
  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.";
13148
+ 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.`;
13149
+ 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.";
13150
+ 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
13151
  var CheckRepliesSchema2 = external_exports.object({}).strict();
13056
13152
  var AGENT_TOOLS2 = [
13057
13153
  { name: "contact", description: CONTACT_DESCRIPTION2, inputSchema: CONTACT_SCHEMA2 },
@@ -13396,9 +13492,9 @@ var AgendaTurnSchema2 = external_exports.object({
13396
13492
  /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
13397
13493
  * the planner declares duplicates instead of asking them twice. */
13398
13494
  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([]),
13495
+ /** The spoken sentences of the turn, in order. No count: how long a turn is is the brain's call
13496
+ * (owner, 2026-09-25), and a count here refused whole plans. */
13497
+ info: external_exports.array(external_exports.string().min(1)).default([]),
13402
13498
  question: external_exports.string().min(1).nullable(),
13403
13499
  /** True on the one turn carrying the agent's own declared question. */
13404
13500
  asks: external_exports.boolean().optional(),
@@ -13433,32 +13529,6 @@ var AgendaTurnSchema2 = external_exports.object({
13433
13529
  var CLAIM_STALE_MS2 = 30 * 6e4;
13434
13530
  var InboxItemSchema2 = external_exports.object({
13435
13531
  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
13532
  tokenId: external_exports.string().optional(),
13463
13533
  status: NotifyStatusSchema2,
13464
13534
  context: ContextSchema2,
@@ -13503,7 +13573,18 @@ var InboxItemSchema2 = external_exports.object({
13503
13573
  entryId: external_exports.string(),
13504
13574
  title: external_exports.string(),
13505
13575
  state: external_exports.enum(["open", "answered"]),
13506
- answer: external_exports.string().nullable()
13576
+ answer: external_exports.string().nullable(),
13577
+ /** WHO ASKED IT (owner, 2026-09-23, Goal a345e906: each agenda row wears its agent's face) — the
13578
+ * request Entry's author, as the same three facts the item's own `tokenId`/`name`/`voice`
13579
+ * carry for the call's one agent, so the phone draws it with the same seed. Absent when the
13580
+ * author is not an agent this account holds (unpaired since, or a person). */
13581
+ agent: external_exports.object({ tokenId: external_exports.string(), name: external_exports.string(), voice: VoiceKeySchema2.optional() }).optional(),
13582
+ /** ITS OPTIONS, WHEN THERE IS SOMETHING TO SEE (owner, 2026-09-25: "Yes, add it"): the options
13583
+ * its need offers, exactly as its own card carries them, present only when one of them has a
13584
+ * preview (`html` or `image`). The call screen opens them from the agenda row, so a preview is
13585
+ * never re-sent as a second card to be seen mid-call. Words-only options are absent — the bot
13586
+ * says those, and the list stays small (an `html` is up to 16 KB). */
13587
+ options: external_exports.array(OptionSchema2).optional()
13507
13588
  })).optional(),
13508
13589
  visuals: external_exports.array(VisualSchema2).optional(),
13509
13590
  /** The connected agent's name (the single pairing name — user-typed, or the
@@ -13521,10 +13602,20 @@ var InboxItemSchema2 = external_exports.object({
13521
13602
  * agent (held in the inbox until the agent replies on the thread). */
13522
13603
  turn: external_exports.enum(["you", "agent"]).default("you"),
13523
13604
  /** 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. */
13605
+ * the agent (provider-agnostic; set server-side). Absent = no hard error. Drives the inbox
13606
+ * error badge + Retry. */
13526
13607
  error: external_exports.string().optional(),
13608
+ /** WHEN THIS AGENT WORK WENT QUIET (turn="agent"), by the one rule (`coldSince`: three days
13609
+ * with nothing said), or absent while it is not stalled. The inbox's stalled badge reads
13610
+ * this and nothing else (2026-09-23: a 3-minute age rule badged every live Goal stalled,
13611
+ * and "dismiss the stalled ones" cancelled 37 pieces of live work). */
13612
+ cold: external_exports.string().datetime().optional(),
13527
13613
  clarifies: external_exports.string().optional(),
13614
+ /** THIS CARD'S QUESTION IS ON A LIVE CALL (owner, 2026-09-24: "Mark it while the call is
13615
+ * live"). Present only while an open Call Delivery carries the card's request Entry — read
13616
+ * off the same open list the card came from, so it clears when the Call does. A card is the
13617
+ * backup for a call not taken; while the call has it, the call is where it is answered. */
13618
+ onCall: external_exports.literal(true).optional(),
13528
13619
  /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
13529
13620
  * declined, and what the ladder will do next — read off the cron's own row, never computed
13530
13621
  * on the phone. Present only while a `declined` receipt stands on the card's last Call.
@@ -13573,7 +13664,10 @@ var InboxItemSchema2 = external_exports.object({
13573
13664
  goalIds: external_exports.array(external_exports.string()),
13574
13665
  decisionNeedId: external_exports.string().optional(),
13575
13666
  sealed: external_exports.boolean(),
13576
- goalState: external_exports.string().optional()
13667
+ goalState: external_exports.string().optional(),
13668
+ /** THAT GOAL'S NAME (#2416) — what Activity's row is headed by, since a row there is one Goal
13669
+ * and the cards it holds sit behind it. Stamped by the same read as `goalState`. */
13670
+ goalTitle: external_exports.string().optional()
13577
13671
  }).optional()
13578
13672
  });
13579
13673
  var APNS_TOKEN_RE2 = /^[0-9a-fA-F]{64}$/;
@@ -13672,7 +13766,6 @@ var UserSettingsSchema2 = external_exports.object({
13672
13766
  });
13673
13767
  var HistoryItemSchema2 = external_exports.object({
13674
13768
  id: external_exports.string(),
13675
- parentId: external_exports.string(),
13676
13769
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
13677
13770
  initiator: external_exports.enum(["user", "agent"]),
13678
13771
  title: external_exports.string(),
@@ -13831,6 +13924,24 @@ var QueueQuestionSchema2 = external_exports.object({
13831
13924
  * gate at all. */
13832
13925
  blocked: external_exports.boolean().default(false)
13833
13926
  });
13927
+ var QueueReplySchema2 = external_exports.object({
13928
+ /** The card this note was (`deliveryId:requestEntryId`, minted by the server like every card
13929
+ * id) — so the phone can tell a reply it just sent from one the queue already carries, and the
13930
+ * walk can name it in its zoom. */
13931
+ id: external_exports.string(),
13932
+ /** The Goal the note is on. */
13933
+ goalId: external_exports.string(),
13934
+ /** What the note said. */
13935
+ note: external_exports.string(),
13936
+ /** Where it was carried — where a second reply goes (`POST /api/entries`, #2252). */
13937
+ deliveryId: external_exports.string(),
13938
+ requestEntryId: external_exports.string(),
13939
+ askedAt: external_exports.string(),
13940
+ /** When the person last replied — the window's start. */
13941
+ repliedAt: external_exports.string(),
13942
+ /** The person's latest words about it; null when there is nothing readable in them. */
13943
+ reply: external_exports.string().nullable()
13944
+ });
13834
13945
  var QueueItemSchema2 = external_exports.object({
13835
13946
  id: external_exports.string(),
13836
13947
  /** One-line headline — the first sentence of the outcome. */
@@ -13858,6 +13969,10 @@ var QueueItemSchema2 = external_exports.object({
13858
13969
  blocked: external_exports.boolean().default(false),
13859
13970
  /** Every decision need on it, open or settled — the page decides which to show. */
13860
13971
  questions: external_exports.array(QueueQuestionSchema2).default([]),
13972
+ /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
13973
+ * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
13974
+ * not spell an empty list. */
13975
+ replies: external_exports.array(QueueReplySchema2).optional(),
13861
13976
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
13862
13977
  repo: external_exports.string().nullable().optional(),
13863
13978
  createdAt: external_exports.string(),
@@ -14070,6 +14185,7 @@ import { resolve as resolve2, delimiter as delimiter2 } from "path";
14070
14185
 
14071
14186
  // src/harness/acp.ts
14072
14187
  var none = { events: [], writes: [] };
14188
+ var NOTHING_TO_RESUME = /* @__PURE__ */ new Set([-32002, -32601]);
14073
14189
  function optionFor(options, decision) {
14074
14190
  const want = decision.allow ? "allow_once" : "reject_once";
14075
14191
  return options.find((o) => o.kind === want)?.optionId ?? null;
@@ -14166,20 +14282,29 @@ var AcpDriver = class {
14166
14282
  if (msg.id === this.initId) {
14167
14283
  this.initId = void 0;
14168
14284
  if (msg.error) {
14169
- return { events: [{ kind: "error", message: `initialize failed: ${msg.error.message}` }], writes: [] };
14285
+ return { events: [{ kind: "error", message: `initialize failed: ${msg.error.message}`, fatal: true }], writes: [] };
14170
14286
  }
14171
14287
  this.sessionNewId = this.nextId++;
14172
14288
  return {
14173
14289
  events: [],
14174
- writes: [frame2(this.resume ? { id: this.sessionNewId, method: "session/resume", params: { sessionId: this.resume, cwd: this.cwd, mcpServers: [] } } : { id: this.sessionNewId, method: "session/new", params: { cwd: this.cwd, mcpServers: [] } })]
14290
+ writes: [frame2(this.resume ? { id: this.sessionNewId, method: "session/resume", params: { sessionId: this.resume, cwd: this.cwd, mcpServers: [] } } : this.sessionNew(this.sessionNewId))]
14175
14291
  };
14176
14292
  }
14177
14293
  if (msg.id === this.sessionNewId) {
14178
14294
  this.sessionNewId = void 0;
14295
+ const resuming = this.resume !== void 0;
14296
+ if (resuming && msg.error?.code !== void 0 && NOTHING_TO_RESUME.has(msg.error.code)) {
14297
+ this.resume = void 0;
14298
+ this.sessionNewId = this.nextId++;
14299
+ return {
14300
+ events: [{ kind: "error", message: `session/resume found nothing to resume (${msg.error.message}) \u2014 opening a new conversation` }],
14301
+ writes: [frame2(this.sessionNew(this.sessionNewId))]
14302
+ };
14303
+ }
14179
14304
  const sessionId2 = msg.error ? void 0 : msg.result?.sessionId ?? this.resume;
14180
14305
  if (!sessionId2) {
14181
14306
  return {
14182
- events: [{ kind: "error", message: `${this.resume ? "session/resume" : "session/new"} failed: ${msg.error?.message ?? "no sessionId"}` }],
14307
+ events: [{ kind: "error", message: `${resuming ? "session/resume" : "session/new"} failed: ${msg.error?.message ?? "no sessionId"}`, fatal: true }],
14183
14308
  writes: []
14184
14309
  };
14185
14310
  }
@@ -14272,6 +14397,9 @@ var AcpDriver = class {
14272
14397
  writes: []
14273
14398
  };
14274
14399
  }
14400
+ sessionNew(id) {
14401
+ return { id, method: "session/new", params: { cwd: this.cwd, mcpServers: [] } };
14402
+ }
14275
14403
  /** Send the queued prompts as one turn, if the agent can take one right now. */
14276
14404
  flush() {
14277
14405
  if (!this.sessionId || this.promptId !== void 0 || !this.queued.length) return [];
@@ -14316,7 +14444,7 @@ function startSession(opts) {
14316
14444
  ...process.env,
14317
14445
  PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
14318
14446
  ...opts.token ? { PAIGY_TOKEN: opts.token } : {},
14319
- ...opts.resume ? { PAIGY_SESSION_ID: opts.resume } : {},
14447
+ ...opts.resume ?? opts.session ? { PAIGY_SESSION_ID: opts.resume ?? opts.session } : {},
14320
14448
  // A PUMP DRIVES THIS PROCESS (2026-09-22, #2265): every run `runHarness` starts has the
14321
14449
  // bridge's pump typing answers in, so nothing inside it may start a second delivery path.
14322
14450
  // Exported on EVERY spawn — `PAIGY_SESSION_ID` above is the conversation to resume, and a
@@ -14326,7 +14454,11 @@ function startSession(opts) {
14326
14454
  },
14327
14455
  stdio: ["pipe", "pipe", "pipe"]
14328
14456
  });
14457
+ let ended2 = false;
14458
+ child.stdin?.on("error", () => {
14459
+ });
14329
14460
  const write = (frames) => {
14461
+ if (ended2) return;
14330
14462
  for (const f of frames) child.stdin?.write(`${f}
14331
14463
  `);
14332
14464
  };
@@ -14335,6 +14467,7 @@ function startSession(opts) {
14335
14467
  if (text) opts.onEvent({ kind: "error", message: text });
14336
14468
  });
14337
14469
  child.on("exit", (code) => {
14470
+ ended2 = true;
14338
14471
  opts.onEvent({ kind: "idle", failed: code !== 0, ...code ? { result: `exited ${code}` } : {} });
14339
14472
  opts.onExit?.();
14340
14473
  });
@@ -14363,6 +14496,7 @@ function startSession(opts) {
14363
14496
  },
14364
14497
  stop() {
14365
14498
  write(driver.close());
14499
+ ended2 = true;
14366
14500
  child.kill();
14367
14501
  }
14368
14502
  };
@@ -14529,6 +14663,7 @@ async function ask(state, rail, input) {
14529
14663
  waiting: "hard",
14530
14664
  channel: input.call ? "call" : "notification"
14531
14665
  });
14666
+ if ("recorded" in sent) return null;
14532
14667
  const entryId = sent.requestEntryIds[0];
14533
14668
  if (!entryId) return null;
14534
14669
  (state.seen ??= /* @__PURE__ */ new Set()).add(entryId);
@@ -14604,6 +14739,23 @@ async function finish(result, state, rail, opts) {
14604
14739
  opts?.log?.(`\u26A0 finish contact failed: ${error.message}`);
14605
14740
  }
14606
14741
  }
14742
+ async function failedToStart(reason, state, rail, opts) {
14743
+ try {
14744
+ const goalId = state.goal?.id ?? (await rail.claim()).goalId;
14745
+ state.goal = void 0;
14746
+ state.resting = true;
14747
+ if (!goalId) return;
14748
+ await rail.contact({
14749
+ asks: [{ ask: `The session could not start, so nothing is working on this yet.
14750
+
14751
+ ${reason}`, parentId: goalId }],
14752
+ waiting: "none",
14753
+ channel: "notification"
14754
+ });
14755
+ } catch (error) {
14756
+ opts?.log?.(`\u26A0 could not report the failed start: ${error.message}`);
14757
+ }
14758
+ }
14607
14759
  async function afterTurn(event, state, rail, opts) {
14608
14760
  if (!event.failed && !endsWithQuestion(event.result)) {
14609
14761
  await finish(event.result, state, rail, opts);
@@ -14628,10 +14780,16 @@ function runHarness(opts) {
14628
14780
  let tail = [];
14629
14781
  const state = { resting: true };
14630
14782
  let session = null;
14631
- const rail = railFor({ token: opts.token, ...opts.resume ? { reach: reachAs(opts.resume) } : {} });
14783
+ const speaksAs = opts.resume ?? opts.session;
14784
+ const rail = railFor({ token: opts.token, ...speaksAs ? { reach: reachAs(speaksAs) } : {} });
14632
14785
  async function handle(event) {
14633
14786
  if (event.kind === "error") {
14634
14787
  opts.log(`\u26A0 ${event.message}`);
14788
+ if (event.fatal && running) {
14789
+ opts.log(`\u2716 could not start: ${event.message}`);
14790
+ await failedToStart(event.message, state, rail, { log: opts.log });
14791
+ stop2();
14792
+ }
14635
14793
  return;
14636
14794
  }
14637
14795
  if (event.kind === "permission") {
@@ -14677,6 +14835,7 @@ function runHarness(opts) {
14677
14835
  mode: opts.mode,
14678
14836
  ...opts.token ? { token: opts.token } : {},
14679
14837
  ...opts.resume ? { resume: opts.resume } : {},
14838
+ ...opts.session ? { session: opts.session } : {},
14680
14839
  ...opts.bin ? { bin: opts.bin } : {},
14681
14840
  ...opts.onExit ? { onExit: opts.onExit } : {},
14682
14841
  onEvent: (event) => void handle(event).catch((err) => opts.log(`\u26A0 ${err.message}`))
@@ -14685,27 +14844,28 @@ function runHarness(opts) {
14685
14844
  (e) => opts.log(`\u26A0 conversation loop stopped: ${e.message}`)
14686
14845
  );
14687
14846
  opts.log(`\u25B6 started ${opts.harness} in ${opts.cwd} (${opts.mode} mode)`);
14847
+ function stop2() {
14848
+ running = false;
14849
+ tail = [];
14850
+ cancelAsks(state);
14851
+ session?.stop();
14852
+ session = null;
14853
+ opts.log("\u25A0 stopped");
14854
+ }
14688
14855
  return {
14689
14856
  send(text) {
14690
14857
  session?.send(text);
14691
14858
  },
14692
14859
  working: () => running && state.resting !== true,
14693
14860
  tail: () => [...tail],
14694
- stop() {
14695
- running = false;
14696
- tail = [];
14697
- cancelAsks(state);
14698
- session?.stop();
14699
- session = null;
14700
- opts.log("\u25A0 stopped");
14701
- }
14861
+ stop: stop2
14702
14862
  };
14703
14863
  }
14704
14864
 
14705
14865
  // src/update.ts
14706
14866
  import { execFile } from "child_process";
14707
14867
  import { createRequire } from "module";
14708
- import { dirname as dirname3, join as join6 } from "path";
14868
+ import { dirname as dirname3, join as join7 } from "path";
14709
14869
  import { promisify } from "util";
14710
14870
  var SERVICE_LABEL = "ai.paigy.harness";
14711
14871
  var VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -14732,7 +14892,7 @@ async function latestVersion() {
14732
14892
  }
14733
14893
  async function install(version) {
14734
14894
  const bin = dirname3(process.execPath);
14735
- await promisify(execFile)(join6(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
14895
+ await promisify(execFile)(join7(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
14736
14896
  env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, npm_config_update_notifier: "false" },
14737
14897
  timeout: 5 * 6e4
14738
14898
  });
@@ -14783,7 +14943,7 @@ function selfUpdater(deps) {
14783
14943
  }
14784
14944
 
14785
14945
  // src/host.ts
14786
- var HOST_FILE = join7(homedir7(), ".paigy", "host.json");
14946
+ var HOST_FILE = join8(homedir7(), ".paigy", "host.json");
14787
14947
  function readHostState() {
14788
14948
  try {
14789
14949
  const s = JSON.parse(readFileSync4(HOST_FILE, "utf8"));
@@ -14807,6 +14967,7 @@ var claimedRun = (sessionId2) => {
14807
14967
  const slot = sessionSlot(sessionId2);
14808
14968
  return { slot, key: runKey(slot) };
14809
14969
  };
14970
+ var launchRun = (spec) => ({ session: spec.sessionId });
14810
14971
  async function slotHasWaitingWork(token, session) {
14811
14972
  return (await checkReplies({ token, ...session ? { reach: reachAs(session) } : {} })).deliveries.length > 0;
14812
14973
  }
@@ -14865,7 +15026,7 @@ function startHost(opts) {
14865
15026
  mode: "bypass",
14866
15027
  prompt: "",
14867
15028
  token: spec.token,
14868
- resume: spec.sessionId,
15029
+ ...launchRun(spec),
14869
15030
  onExit: () => {
14870
15031
  runs.delete(key);
14871
15032
  opts.log(`\u2716 ${label} ended`);
@@ -15646,7 +15807,7 @@ var win = null;
15646
15807
  function log(message) {
15647
15808
  win?.webContents.send("paigy:log", message);
15648
15809
  }
15649
- var ICON = join8(here, "..", "assets", "icon.png");
15810
+ var ICON = join9(here, "..", "assets", "icon.png");
15650
15811
  function createWindow() {
15651
15812
  win = new BrowserWindow({
15652
15813
  // Sized for the working log, which is now the window's centerpiece; the layout is a
@@ -15658,9 +15819,9 @@ function createWindow() {
15658
15819
  minHeight: 520,
15659
15820
  icon: ICON,
15660
15821
  // Windows/Linux window icon; macOS uses the dock icon below
15661
- webPreferences: { preload: join8(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
15822
+ webPreferences: { preload: join9(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
15662
15823
  });
15663
- void win.loadFile(join8(here, "..", "renderer", "index.html"));
15824
+ void win.loadFile(join9(here, "..", "renderer", "index.html"));
15664
15825
  }
15665
15826
  var host = null;
15666
15827
  app.whenReady().then(() => {
@@ -15729,7 +15890,7 @@ ipcMain.handle("paigy:agent", async (_e, name) => {
15729
15890
  });
15730
15891
  ipcMain.handle("paigy:agent-log", (_e, name) => {
15731
15892
  try {
15732
- const all = readFileSync5(join8(homedir9(), ".paigy", "host.log"), "utf8").split("\n");
15893
+ const all = readFileSync5(join9(homedir9(), ".paigy", "host.log"), "utf8").split("\n");
15733
15894
  const mine = all.filter((l) => l.includes(`[${name}]`) || /[▶✖◉⚠]/.test(l) && l.includes(name));
15734
15895
  return mine.slice(-500).join("\n");
15735
15896
  } catch {