@paigy/harness 0.3.14 → 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 +434 -235
  2. package/dist/main.js +431 -233
  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";
@@ -8638,6 +8638,7 @@ import { randomUUID as randomUUID2 } from "crypto";
8638
8638
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
8639
8639
  import { homedir as homedir2 } from "os";
8640
8640
  import { join as join2 } from "path";
8641
+ import { execFileSync as execFileSync2 } from "child_process";
8641
8642
  var import_undici = __toESM(require_undici(), 1);
8642
8643
  var require2 = __sdkCreateRequire(import.meta.url);
8643
8644
  var BACKEND_URL = process.env.PAIGY_BACKEND_URL ?? "https://paigy.ai";
@@ -9921,6 +9922,23 @@ var zodToJsonSchema = (schema, options) => {
9921
9922
  };
9922
9923
  var OPTIONS_MIN = 2;
9923
9924
  var OPTIONS_MAX = 6;
9925
+ var OptionSchema = external_exports.object({
9926
+ id: external_exports.string(),
9927
+ label: external_exports.string(),
9928
+ hint: external_exports.string().max(500).describe("Optional short projection of consequence or action if this option is chosen (e.g. 'Reruns test suite', 'Merges to main').").optional(),
9929
+ // .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
9930
+ // the constraints below are what an agent reads when deciding to use these.
9931
+ html: external_exports.string().max(16384).describe(
9932
+ "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Rendered edge-to-edge in a responsive card that is 200pt tall (about 320pt wide on a phone, with the next option peeking beside it); make your HTML fit that viewport. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
9933
+ ).optional(),
9934
+ image: external_exports.string().url().describe(
9935
+ "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
9936
+ ).optional()
9937
+ });
9938
+ var OptionInputSchema = OptionSchema.omit({ id: true }).extend({
9939
+ label: external_exports.string().trim().min(1).max(1e3)
9940
+ }).strict();
9941
+ var NIGHT = { from: 23, to: 7 };
9924
9942
  function draft2020(node) {
9925
9943
  if (Array.isArray(node)) return node.map(draft2020);
9926
9944
  if (node && typeof node === "object") {
@@ -9952,26 +9970,21 @@ function mcpInputSchema(s) {
9952
9970
  }
9953
9971
  var AskInputSchema = external_exports.object({
9954
9972
  id: external_exports.string().optional().describe("Optional idempotency key or client-side ID for this specific ask."),
9955
- parentId: external_exports.string().uuid().optional().describe("Optional explicit parent Goal ID. If omitted, the platform's semantic router will auto-cluster it."),
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."),
9956
9974
  repo: external_exports.string().optional().describe("Optional repository context."),
9957
9975
  ask: external_exports.string().trim().min(1).max(1e4).describe(
9958
- "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."
9959
9977
  ),
9960
- options: external_exports.array(external_exports.object({
9961
- label: external_exports.string().trim().min(1).max(1e3),
9962
- hint: external_exports.string().trim().max(500).describe("Optional short projection of consequence or action if this option is chosen.").optional(),
9963
- image: external_exports.string().url().optional(),
9964
- html: external_exports.string().max(16384).describe("Optional sandboxed HTML/CSS preview. No JavaScript or network; inline CSS and data: URIs only. It renders edge-to-edge in a responsive card 200pt tall (about 320pt wide on a phone, with the next option peeking beside it), so fit the HTML to that viewport.").optional()
9965
- }).strict()).min(2).max(6).optional()
9978
+ options: external_exports.array(OptionInputSchema).min(2).max(6).optional()
9966
9979
  }).strict();
9967
9980
  var StartContactSchema = external_exports.object({
9968
- asks: external_exports.array(AskInputSchema).min(1).describe("The list of asks/questions to pose. They will be semantically clustered by the API."),
9981
+ asks: external_exports.array(AskInputSchema).min(1).describe("The questions to pose, one per object. An ask with a parentId is filed on that Goal as it is; only an ask naming no Goal is placed in the tree by Paigy."),
9969
9982
  waiting: external_exports.enum(["none", "hard"]).default("none"),
9970
9983
  channel: external_exports.enum(["notification", "call"]).default("notification")
9971
9984
  }).strict();
9972
9985
  var ContactSchema = external_exports.union([StartContactSchema, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
9973
9986
  var CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
9974
- 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'. The platform will semantically cluster your asks under the correct Goals automatically. 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).";
9975
9988
  var CreateGoalSchema = external_exports.object({
9976
9989
  outcome: external_exports.string().trim().min(1).max(1e4),
9977
9990
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -9979,10 +9992,6 @@ var CreateGoalSchema = external_exports.object({
9979
9992
  title: external_exports.string().trim().min(1).max(80).optional(),
9980
9993
  ownerParticipant: external_exports.string().trim().min(1).optional(),
9981
9994
  idempotencyKey: external_exports.string().trim().min(1).max(200),
9982
- /** A past conversation this Goal should be read against — History's "new session from this"
9983
- * (owner, on the call of 2026-09-14: "let's do the reference with the threading"). A
9984
- * reference only: the owner reads it through `get_thread`, which does its own scoping, and
9985
- * the writer refuses a thread belonging to another account. */
9986
9995
  /** THE GOAL THIS ONE BELONGS UNDER (owner, 2026-09-15: "the ask I gave for the design doc
9987
9996
  * didn't get created as a child goal of the voice UI goal, which is how it should've
9988
9997
  * worked"). It could not have been: this door took no parent, so the only route was
@@ -10022,23 +10031,13 @@ var UpdateGoalToolSchema = UpdateGoalSchema.omit({ operationId: true }).extend({
10022
10031
  var ClaimGoalSchema = external_exports.object({ goalId: external_exports.string().uuid().optional() }).strict();
10023
10032
  var GetGoalSchema = external_exports.object({ goalId: external_exports.string().uuid() }).strict();
10024
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.";
10025
- 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.";
10026
- 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.";
10027
- 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.";
10028
10037
  var CheckRepliesSchema = external_exports.object({}).strict();
10029
- var GetThreadSchema = external_exports.object({
10030
- parentId: external_exports.string().describe("The Thread to read \u2014 the threadId a Delivery returned, or the parentId of a search hit.")
10031
- }).strict();
10032
- var GET_THREAD_DESCRIPTION = "Read the authorized durable Entries on one conversation Thread \u2014 what you wrote there and what was delivered to you, oldest first. Use claim_goal to find the work to resume; use this to rehydrate a Thread that a search hit or a Delivery named.";
10033
- var SearchThreadsSchema = external_exports.object({
10034
- q: external_exports.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
10035
- }).strict();
10036
- var SEARCH_THREADS_DESCRIPTION = `Search your PAST conversations before asking \u2014 "have we discussed this before?". Full-text over your own threads (the asks you sent + the user's answers); returns ranked threads with highlighted snippets, NOT rows: { hits: [{ parentId, at, agentLabel, matches: [{ notificationId, role, snippet }] }] }. The loop this exists for: search first \u2192 get_thread the best hit to rehydrate it \u2192 THEN continue or contact, so you answer with receipts ("last week you said ship it") instead of re-asking. Read-only, safe to call anytime; scoped to your own account's threads.`;
10037
10038
  var AGENT_TOOLS = [
10038
10039
  { name: "contact", description: CONTACT_DESCRIPTION, inputSchema: CONTACT_SCHEMA },
10039
10040
  { name: "check_replies", description: CHECK_REPLIES_DESCRIPTION, inputSchema: mcpInputSchema(CheckRepliesSchema) },
10040
- { name: "get_thread", description: GET_THREAD_DESCRIPTION, inputSchema: mcpInputSchema(GetThreadSchema) },
10041
- { name: "search_threads", description: SEARCH_THREADS_DESCRIPTION, inputSchema: mcpInputSchema(SearchThreadsSchema) },
10042
10041
  { name: "create_goal", description: CREATE_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(CreateGoalToolSchema) },
10043
10042
  { name: "claim_goal", description: CLAIM_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(ClaimGoalSchema) },
10044
10043
  { name: "get_goal", description: GET_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(GetGoalSchema) },
@@ -10072,19 +10071,6 @@ var TransformSchema = external_exports.enum([
10072
10071
  "summarize"
10073
10072
  // reduce volume, keep decision value — 30-turn cap, spoken briefing
10074
10073
  ]);
10075
- var OptionSchema = external_exports.object({
10076
- id: external_exports.string(),
10077
- label: external_exports.string(),
10078
- hint: external_exports.string().max(500).describe("Optional short projection of consequence or action if this option is chosen (e.g. 'Reruns test suite', 'Merges to main').").optional(),
10079
- // .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
10080
- // the constraints below are what an agent reads when deciding to use these.
10081
- html: external_exports.string().max(16384).describe(
10082
- "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Rendered edge-to-edge in a responsive card that is 200pt tall (about 320pt wide on a phone, with the next option peeking beside it); make your HTML fit that viewport. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
10083
- ).optional(),
10084
- image: external_exports.string().url().describe(
10085
- "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
10086
- ).optional()
10087
- });
10088
10074
  var VisualSchema = external_exports.object({
10089
10075
  url: external_exports.string().url(),
10090
10076
  label: external_exports.string().optional()
@@ -10138,7 +10124,7 @@ var NotifyRequestFields = external_exports.object({
10138
10124
  * ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
10139
10125
  * superRefine at the bottom enforces exactly one of the two. */
10140
10126
  context: ContextSchema.optional(),
10141
- options: external_exports.array(OptionSchema.omit({ id: true })).min(OPTIONS_MIN).max(OPTIONS_MAX).optional().describe(
10127
+ options: external_exports.array(OptionInputSchema).min(OPTIONS_MIN).max(OPTIONS_MAX).optional().describe(
10142
10128
  "The choices, in order \u2014 required when select is 'one'/'many'/'rank', omitted otherwise. Ids are assigned automatically by position ('1', '2', \u2026); the user's answer references them as optionId(s)."
10143
10129
  ),
10144
10130
  points: external_exports.array(external_exports.string().min(1)).optional().describe(
@@ -10332,7 +10318,7 @@ var AwaitItemSchema = external_exports.discriminatedUnion("type", [
10332
10318
  /** The awaited ask was REPLACED by a newer notification on its thread (e.g. a
10333
10319
  * post-feedback revision, #633) — the user will never answer this id. Stop
10334
10320
  * awaiting it; the live ask is the thread's newest turn (await that one, or
10335
- * re-orient via get_thread / check_replies). */
10321
+ * re-orient via check_replies). */
10336
10322
  external_exports.object({
10337
10323
  type: external_exports.literal("superseded"),
10338
10324
  parentId: external_exports.string(),
@@ -10377,9 +10363,9 @@ var AgendaTurnSchema = external_exports.object({
10377
10363
  /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
10378
10364
  * the planner declares duplicates instead of asking them twice. */
10379
10365
  coveredIds: external_exports.array(external_exports.string()).optional(),
10380
- /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
10381
- * line went out on 2026-07-28 and the caller could not answer it at all. */
10382
- 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([]),
10383
10369
  question: external_exports.string().min(1).nullable(),
10384
10370
  /** True on the one turn carrying the agent's own declared question. */
10385
10371
  asks: external_exports.boolean().optional(),
@@ -10414,42 +10400,12 @@ var AgendaTurnSchema = external_exports.object({
10414
10400
  var CLAIM_STALE_MS = 30 * 6e4;
10415
10401
  var InboxItemSchema = external_exports.object({
10416
10402
  id: external_exports.string(),
10417
- /** The conversation thread + connection this item lives on. Present on the replied
10418
- * detail — they power History's "Continue" / "New session from this" (#57/#251). */
10419
- parentId: external_exports.string().optional(),
10420
- /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
10421
- * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
10422
- * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
10423
- * accumulates every message an agent ever sent, so grouping by it renders a day of
10424
- * unrelated updates as a single "12-part request". Absent on rows written before the
10425
- * `asks` table, and on anything that never went through `notify` — both fall back to the
10426
- * thread, which is what the client did for all rows until now. */
10427
- askId: external_exports.string().optional(),
10428
- /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
10429
- * shares one `created_at` to the microsecond, so without it the author's order is
10430
- * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
10431
- * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
10432
- * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
10433
- seq: external_exports.number().int().optional(),
10434
- /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
10435
- * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
10436
- * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
10437
- * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
10438
- * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
10439
- * server that can still see every row states it. Absent on any row with no `askId`: a
10440
- * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
10441
- * the one part of its own arrival a single row cannot answer. */
10442
- units: external_exports.number().int().positive().optional(),
10443
10403
  tokenId: external_exports.string().optional(),
10444
10404
  status: NotifyStatusSchema,
10445
10405
  context: ContextSchema,
10446
10406
  options: external_exports.array(OptionSchema).optional(),
10447
10407
  /** The ask's declared coverage points (#396), when the agent sent them. */
10448
10408
  points: external_exports.array(external_exports.string()).optional(),
10449
- /** The call's AGENDA (broker/agenda-design.md): the ordered turns it is made of, built at
10450
- * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
10451
- * between them could not express a call as a sequence. `question: null` is a real turn —
10452
- * a status update stays a statement instead of being shaped into a yes/no. */
10453
10409
  /** Does this claim want an ANSWER, or is it telling you something? Written per row from
10454
10410
  * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
10455
10411
  * its acknowledge affordance: without it a status update offers a text box and a dismiss,
@@ -10470,7 +10426,31 @@ var InboxItemSchema = external_exports.object({
10470
10426
  * instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
10471
10427
  * `createdAt`. */
10472
10428
  agentStateAt: external_exports.string().datetime().optional(),
10473
- agenda: external_exports.array(AgendaTurnSchema).optional(),
10429
+ /** THE QUESTIONS A CALL CARRIES — the call screen's agenda spine (walk/design.md §11, owner
10430
+ * 2026-09-22). One per DecisionNeed on the Call, in the Call's order, answered or open (a
10431
+ * superseded or cancelled need is no longer a question anyone is asked). Present only on a
10432
+ * Call's cards, and every card of that Call carries the same list: the call screen reads it
10433
+ * once, off the one read it already makes (`GET /api/inbox/:callId`).
10434
+ *
10435
+ * It counts DECISIONS, not agenda turns: turns include context-only lines and are re-planned
10436
+ * every cycle, so a spine drawn from them would change length under the caller mid-call.
10437
+ *
10438
+ * `entryId` is the request Entry — the id the bot calls a CLAIM, and the one it names on the
10439
+ * `turn` topic (`asking`, `settled`), because the bot never sees a DecisionNeed id. `title` is
10440
+ * the card's own concise heading; `answer` the accepted answer in words, null while open. It
10441
+ * REPLACED `agenda` (turns), which nothing ever filled. */
10442
+ questions: external_exports.array(external_exports.object({
10443
+ id: external_exports.string(),
10444
+ entryId: external_exports.string(),
10445
+ title: external_exports.string(),
10446
+ state: external_exports.enum(["open", "answered"]),
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
+ })).optional(),
10474
10454
  visuals: external_exports.array(VisualSchema).optional(),
10475
10455
  /** The connected agent's name (the single pairing name — user-typed, or the
10476
10456
  * agent's suggestion, or a default silly name). */
@@ -10487,16 +10467,35 @@ var InboxItemSchema = external_exports.object({
10487
10467
  * agent (held in the inbox until the agent replies on the thread). */
10488
10468
  turn: external_exports.enum(["you", "agent"]).default("you"),
10489
10469
  /** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
10490
- * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
10491
- * 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. */
10492
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(),
10493
10478
  clarifies: external_exports.string().optional(),
10494
- /** The ring ladder ran out while this was still pending — we tried to reach you and
10495
- * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
10496
- * agent with nothing to say, which the roster drew identically until now: "nothing to
10497
- * say" and "gave up saying it" are opposite situations wearing the same face
10498
- * (navigation-design.md, gap 1). False for anything that never rang. */
10499
- gaveUp: external_exports.boolean().default(false),
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(),
10484
+ /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
10485
+ * declined, and what the ladder will do next — read off the cron's own row, never computed
10486
+ * on the phone. Present only while a `declined` receipt stands on the card's last Call.
10487
+ * The ladder is ACCOUNT-WIDE (#2259): `anchorAt` and `step` are the account's position;
10488
+ * `nextRingAt` is this card's armed instant (`deliveries.next_ring_at`), null once the cron
10489
+ * has disarmed it — the curve's end, `inbox`/`dismiss`, or a setting that said no more rings.
10490
+ * It replaced `gaveUp` (deleted 2026-09-22): "the ladder spent" was a boolean the projection
10491
+ * never set, and it is `nextRingAt === null` here — the party's *Missed you* (`party/dress.ts`)
10492
+ * and the roster's `unreached` read `declinedAt`, and stand while it does. */
10493
+ ring: external_exports.object({
10494
+ declinedAt: external_exports.string().datetime(),
10495
+ anchorAt: external_exports.string().datetime(),
10496
+ nextRingAt: external_exports.string().datetime().nullable(),
10497
+ step: external_exports.number().int()
10498
+ }).optional(),
10500
10499
  /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
10501
10500
  * Absent for anything never delivered through a push, and for older rows written before
10502
10501
  * the reason was recorded. Deliberately a debug affordance, shown small (owner,
@@ -10530,7 +10529,10 @@ var InboxItemSchema = external_exports.object({
10530
10529
  goalIds: external_exports.array(external_exports.string()),
10531
10530
  decisionNeedId: external_exports.string().optional(),
10532
10531
  sealed: external_exports.boolean(),
10533
- 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()
10534
10536
  }).optional()
10535
10537
  });
10536
10538
  var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
@@ -10561,6 +10563,8 @@ var MissedCallSchema = external_exports.enum([
10561
10563
  "inbox",
10562
10564
  "dismiss"
10563
10565
  ]);
10566
+ var clock = (h) => h === 0 ? "midnight" : h === 12 ? "noon" : h < 12 ? `${h} am` : `${h - 12} pm`;
10567
+ var QUIET = ` Nothing rings from ${clock(NIGHT.from)} to ${clock(NIGHT.to)} your time; the count waits for morning.`;
10564
10568
  var BrokerTuningSchema = external_exports.object({
10565
10569
  /** 'none' = skip the spoken ack after a mapped answer (power users find it slow). */
10566
10570
  ackVerbosity: external_exports.enum(["normal", "none"]).optional(),
@@ -10595,6 +10599,12 @@ var UserSettingsSchema = external_exports.object({
10595
10599
  * must not silently reset this privacy choice. Absent = leave unchanged on
10596
10600
  * write, 'hosted' on read (see store.ts). */
10597
10601
  voiceMode: external_exports.enum(["hosted", "on_device"]).optional(),
10602
+ /** Talk — after you answer, the next step is read aloud (walk/design.md §6). ALWAYS ON until
10603
+ * turned off (owner, 2026-09-18, #2249): a setting, not a per-walk toggle. Optional, NOT
10604
+ * defaulted, for the same reason `voiceMode` is: a stale client PATCHing the full settings
10605
+ * object must not silently turn it back on. Absent = leave unchanged on write, true on
10606
+ * read (see store.ts). */
10607
+ talk: external_exports.boolean().optional(),
10598
10608
  /** Per-user ring budget (#603): calls per rolling day before further calls
10599
10609
  * degrade to banner. Absent = the global default (25). A number, never a
10600
10610
  * bypass — every account keeps a ceiling. No UI; set per user for testing. */
@@ -10621,7 +10631,6 @@ var UserSettingsSchema = external_exports.object({
10621
10631
  });
10622
10632
  var HistoryItemSchema = external_exports.object({
10623
10633
  id: external_exports.string(),
10624
- parentId: external_exports.string(),
10625
10634
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
10626
10635
  initiator: external_exports.enum(["user", "agent"]),
10627
10636
  title: external_exports.string(),
@@ -10780,6 +10789,24 @@ var QueueQuestionSchema = external_exports.object({
10780
10789
  * gate at all. */
10781
10790
  blocked: external_exports.boolean().default(false)
10782
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
+ });
10783
10810
  var QueueItemSchema = external_exports.object({
10784
10811
  id: external_exports.string(),
10785
10812
  /** One-line headline — the first sentence of the outcome. */
@@ -10792,6 +10819,9 @@ var QueueItemSchema = external_exports.object({
10792
10819
  assignee: external_exports.string().nullable().default(null),
10793
10820
  /** What the agent last said it was doing; null if it has said nothing. */
10794
10821
  progress: external_exports.string().nullable().default(null),
10822
+ /** HOME'S LINE FOR THAT NOTE (owner, 2026-09-23): a few plain words one read wrote from `progress`,
10823
+ * served only while it was written for the current note. Null means show the Goal's name. */
10824
+ progressLine: external_exports.string().nullable().optional(),
10795
10825
  reviewPending: external_exports.boolean().default(false),
10796
10826
  dueAt: external_exports.string().nullable().default(null),
10797
10827
  /** The Goal this one was opened under; null at the root. */
@@ -10804,11 +10834,20 @@ var QueueItemSchema = external_exports.object({
10804
10834
  blocked: external_exports.boolean().default(false),
10805
10835
  /** Every decision need on it, open or settled — the page decides which to show. */
10806
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(),
10807
10841
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
10808
10842
  repo: external_exports.string().nullable().optional(),
10809
10843
  createdAt: external_exports.string(),
10810
- updatedAt: external_exports.string().nullable().default(null)
10844
+ updatedAt: external_exports.string().nullable().default(null),
10845
+ /** When its owner last SAID something about it (`goals.last_progress_at`, written by every
10846
+ * `update_goal` that changes `progress`). `updatedAt` moves for reasons nobody chose — a
10847
+ * state recomputed, a review flag — so it cannot tell work in hand from work gone quiet. */
10848
+ lastProgressAt: external_exports.string().nullable().optional()
10811
10849
  });
10850
+ var COLD_AFTER_MS = 3 * 24 * 60 * 60 * 1e3;
10812
10851
  var NoteSourceSchema = external_exports.enum(["app", "call"]);
10813
10852
  var NoteStatusSchema = external_exports.enum(["open", "assigned", "in_progress", "done"]);
10814
10853
  var NoteRepeatSchema = external_exports.enum(["once", "until_done"]);
@@ -11109,6 +11148,8 @@ var ApiError = class extends Error {
11109
11148
  this.body = body;
11110
11149
  }
11111
11150
  };
11151
+ var tokenOverride = null;
11152
+ var authToken = (explicit) => explicit ?? tokenOverride ?? readToken();
11112
11153
  async function fail(what, res) {
11113
11154
  throw new ApiError(what, res.status, await res.text());
11114
11155
  }
@@ -11145,8 +11186,6 @@ async function updateGoal(goalId, input, opts = {}) {
11145
11186
  return await res.json();
11146
11187
  }
11147
11188
  var AWAIT_WINDOW_MS = 45e3;
11148
- var tokenOverride = null;
11149
- var authToken = (explicit) => explicit ?? tokenOverride ?? readToken();
11150
11189
  async function whoAmI(opts = {}) {
11151
11190
  try {
11152
11191
  const res = await reach(`${BACKEND_URL}/api/identity`, {
@@ -11233,6 +11272,33 @@ async function dismissTriage(proposalId, opts = {}) {
11233
11272
  if (!res.ok) throw new Error(`dismiss_triage failed: ${res.status} ${await res.text()}`);
11234
11273
  return await res.json();
11235
11274
  }
11275
+ function repoFromRemote(url) {
11276
+ const said = (url ?? "").trim();
11277
+ if (!said) return null;
11278
+ const path = said.replace(/^[a-z+]+:\/\/[^/]+\//i, "").replace(/^[^@]+@[^:]+:/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
11279
+ const parts = path.split("/").filter(Boolean);
11280
+ if (parts.length < 2) return null;
11281
+ const [owner, name] = parts.slice(-2);
11282
+ if (!owner || !name) return null;
11283
+ return `${owner}/${name}`.toLowerCase();
11284
+ }
11285
+ var asked;
11286
+ function currentRepo(cwd = process.cwd()) {
11287
+ if (asked !== void 0) return asked;
11288
+ try {
11289
+ const url = execFileSync2("git", ["remote", "get-url", "origin"], {
11290
+ cwd,
11291
+ encoding: "utf8",
11292
+ timeout: 2e3,
11293
+ stdio: ["ignore", "pipe", "ignore"]
11294
+ });
11295
+ asked = repoFromRemote(url);
11296
+ } catch {
11297
+ asked = null;
11298
+ }
11299
+ return asked;
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.";
11236
11302
  async function contact(input, opts = {}) {
11237
11303
  const parsed = ContactSchema.parse(input);
11238
11304
  opts.signal?.throwIfAborted();
@@ -11240,13 +11306,18 @@ async function contact(input, opts = {}) {
11240
11306
  const headers = { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token) ?? ""}`, "x-paigy-model": "goal-entry-v1" };
11241
11307
  let deliveryId;
11242
11308
  let deliveries = [];
11309
+ let recorded = [];
11243
11310
  if ("deliveryId" in parsed) {
11244
11311
  deliveryId = parsed.deliveryId;
11245
11312
  } else {
11246
11313
  const reqAsks = parsed.asks.map((a) => ({
11247
11314
  id: a.id,
11248
11315
  parentId: a.parentId,
11249
- repo: a.repo,
11316
+ // Derived, not asked for (#2315): the agent's own remote, in one spelling, so a project's
11317
+ // work does not grow three trees for "paigy", "mauurda/paigy" and "Paigy". An explicit one
11318
+ // still wins — normalised the same way — because a caller that names one knows something
11319
+ // this cannot see.
11320
+ repo: repoFromRemote(a.repo) ?? a.repo ?? currentRepo() ?? void 0,
11250
11321
  ask: a.ask,
11251
11322
  options: a.options ?? []
11252
11323
  }));
@@ -11264,14 +11335,24 @@ async function contact(input, opts = {}) {
11264
11335
  if (!res.ok) await fail("batch contact", res);
11265
11336
  const receipt = await res.json();
11266
11337
  deliveries = receipt.deliveries ?? [];
11338
+ recorded = receipt.recorded ?? [];
11339
+ if (deliveries.length === 0 && recorded.length) return { sent: false, recorded, message: NOT_SENT };
11267
11340
  if (deliveries.length === 0) throw new Error("Goal contact returned no Delivery identities");
11268
11341
  deliveryId = deliveries[0]?.deliveryId ?? "";
11269
11342
  if (!deliveryId) throw new Error("Goal contact returned no valid Delivery identity");
11270
11343
  }
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;
11271
11346
  const read = async (signal2) => {
11272
11347
  const res = ensureAuthed(await send22(`${BACKEND_URL}/api/deliveries/${encodeURIComponent(deliveryId)}`, { headers, signal: signal2 }));
11273
11348
  if (!res.ok) await fail("read_delivery", res);
11274
- return await res.json();
11349
+ const one = await res.json();
11350
+ return {
11351
+ ...one,
11352
+ ...all.length > 1 ? { deliveries: all } : {},
11353
+ ...recorded.length ? { recorded, notSent: NOT_SENT } : {},
11354
+ ...joinedCard ? { joinedCard: true } : {}
11355
+ };
11275
11356
  };
11276
11357
  const settled = (d) => d.kind === "notification" || d.state === "closed" || d.answers.length > 0 || d.entries.some((e) => e.kind === "contribution");
11277
11358
  if (opts.waits === false) return read(opts.signal);
@@ -11301,47 +11382,108 @@ async function checkReplies(opts = {}) {
11301
11382
  var HEARTBEAT_MS = 25e3;
11302
11383
  var RETRY_MS = [1e3, 2e3, 5e3, 1e4, 3e4];
11303
11384
  var JOIN = { config: { broadcast: { self: false } } };
11385
+ var JOIN_TIMEOUT_MS = 1e4;
11304
11386
  var lines = /* @__PURE__ */ new Map();
11305
11387
  function frame(line, topic, event, payload) {
11388
+ const ref = String(++line.ref);
11306
11389
  try {
11307
- line.ws?.send(JSON.stringify({ topic, event, payload, ref: String(++line.ref) }));
11390
+ line.ws?.send(JSON.stringify({ topic, event, payload, ref }));
11308
11391
  } catch {
11309
11392
  }
11393
+ return ref;
11310
11394
  }
11311
11395
  function stop(line) {
11312
11396
  line.open = false;
11397
+ line.beat = void 0;
11313
11398
  clearInterval(line.heart);
11314
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);
11315
11426
  }
11316
11427
  function connect(line) {
11317
11428
  const base = line.url.replace(/^http/, "ws").replace(/\/+$/, "");
11318
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
+ };
11319
11438
  ws.addEventListener("open", () => {
11439
+ if (gone) return;
11320
11440
  line.open = true;
11321
11441
  line.attempt = 0;
11322
- for (const topic of line.topics.keys()) frame(line, `realtime:${topic}`, "phx_join", JOIN);
11323
- 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);
11324
11454
  });
11325
11455
  ws.addEventListener("message", (e) => {
11456
+ if (gone) return;
11326
11457
  let m;
11327
11458
  try {
11328
11459
  m = JSON.parse(String(e.data));
11329
11460
  } catch {
11330
11461
  return;
11331
11462
  }
11332
- if (m.event !== "broadcast" || !m.topic?.startsWith("realtime:")) return;
11333
- 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) {
11334
11484
  if (m.payload?.event === l.event) l.onMessage(m.payload.payload);
11335
11485
  }
11336
11486
  });
11337
- let gone = false;
11338
- const again = () => {
11339
- if (gone) return;
11340
- gone = true;
11341
- stop(line);
11342
- if (!line.topics.size) return;
11343
- line.retry = setTimeout(() => connect(line), RETRY_MS[Math.min(line.attempt++, RETRY_MS.length - 1)]);
11344
- };
11345
11487
  ws.addEventListener("close", again);
11346
11488
  ws.addEventListener("error", again);
11347
11489
  }
@@ -11349,17 +11491,22 @@ function subscribeRealtime(args) {
11349
11491
  const key = `${args.url} ${args.anonKey}`;
11350
11492
  let line = lines.get(key);
11351
11493
  if (!line) lines.set(key, line = { url: args.url, anonKey: args.anonKey, open: false, attempt: 0, ref: 0, topics: /* @__PURE__ */ new Map() });
11352
- let heard = line.topics.get(args.topic);
11353
- if (!heard) {
11354
- line.topics.set(args.topic, heard = /* @__PURE__ */ new Set());
11355
- if (line.open) frame(line, `realtime:${args.topic}`, "phx_join", JOIN);
11356
- }
11357
- const me = { event: args.event, onMessage: args.onMessage };
11358
- 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
+ });
11359
11505
  if (!line.ws) connect(line);
11360
11506
  return {
11361
11507
  close: () => {
11362
- if (!heard.delete(me) || heard.size) return;
11508
+ if (!topic.listeners.delete(me) || topic.listeners.size) return;
11509
+ clearTimeout(topic.timer);
11363
11510
  line.topics.delete(args.topic);
11364
11511
  if (line.topics.size) {
11365
11512
  if (line.open) frame(line, `realtime:${args.topic}`, "phx_leave", {});
@@ -11375,7 +11522,8 @@ function subscribeRealtime(args) {
11375
11522
  };
11376
11523
  }
11377
11524
  async function subscribeWake(onNudge, opts = {}) {
11378
- const cfg = await registerDelivery("self_hosted", opts);
11525
+ const { release = true, onJoined, ...client } = opts;
11526
+ const cfg = await registerDelivery("self_hosted", client);
11379
11527
  const { url, anonKey } = cfg.realtime;
11380
11528
  const channel = wakeChannel(cfg.tokenId);
11381
11529
  const ch = subscribeRealtime({
@@ -11383,14 +11531,15 @@ async function subscribeWake(onNudge, opts = {}) {
11383
11531
  anonKey,
11384
11532
  topic: channel,
11385
11533
  event: WAKE_EVENT,
11386
- onMessage: (payload) => onNudge(payload ?? {})
11534
+ onMessage: (payload) => onNudge(payload ?? {}),
11535
+ onJoined
11387
11536
  });
11388
11537
  return {
11389
11538
  tokenId: cfg.tokenId,
11390
11539
  channel,
11391
11540
  close: async () => {
11392
11541
  ch.close();
11393
- await registerDelivery("poll", opts).catch(() => {
11542
+ if (release) await registerDelivery("poll", client).catch(() => {
11394
11543
  });
11395
11544
  }
11396
11545
  };
@@ -11404,7 +11553,7 @@ import { homedir as homedir9, hostname } from "os";
11404
11553
  import { spawnSync } from "child_process";
11405
11554
  import { existsSync as existsSync2 } from "fs";
11406
11555
  import { homedir as homedir3 } from "os";
11407
- import { delimiter, dirname, join as join3 } from "path";
11556
+ import { delimiter, dirname, join as join4 } from "path";
11408
11557
  var CATALOG = [
11409
11558
  {
11410
11559
  name: "claude",
@@ -11440,10 +11589,10 @@ var CATALOG = [
11440
11589
  function probeDirs(home = homedir3()) {
11441
11590
  return [
11442
11591
  dirname(process.execPath),
11443
- join3(home, ".local", "bin"),
11592
+ join4(home, ".local", "bin"),
11444
11593
  "/usr/local/bin",
11445
11594
  "/opt/homebrew/bin",
11446
- join3(home, ".npm-global", "bin")
11595
+ join4(home, ".npm-global", "bin")
11447
11596
  ];
11448
11597
  }
11449
11598
  function which(bin) {
@@ -11451,7 +11600,7 @@ function which(bin) {
11451
11600
  const fromPath = hit.status === 0 ? hit.stdout.trim() : "";
11452
11601
  if (fromPath) return fromPath;
11453
11602
  for (const dir of probeDirs()) {
11454
- const candidate = join3(dir, bin);
11603
+ const candidate = join4(dir, bin);
11455
11604
  if (existsSync2(candidate)) return candidate;
11456
11605
  }
11457
11606
  return null;
@@ -11493,14 +11642,14 @@ function detectAll(deps = {}) {
11493
11642
  // src/host.ts
11494
11643
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
11495
11644
  import { homedir as homedir7 } from "os";
11496
- import { join as join7 } from "path";
11645
+ import { join as join8 } from "path";
11497
11646
 
11498
11647
  // src/workspaces.ts
11499
11648
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
11500
- import { dirname as dirname2, join as join4, resolve } from "path";
11649
+ import { dirname as dirname2, join as join5, resolve } from "path";
11501
11650
  import { homedir as homedir4 } from "os";
11502
11651
  function workspacesFile() {
11503
- return join4(homedir4(), ".paigy", "workspaces.json");
11652
+ return join5(homedir4(), ".paigy", "workspaces.json");
11504
11653
  }
11505
11654
  function listWorkspaces(deps) {
11506
11655
  try {
@@ -11541,7 +11690,7 @@ function resolveWakeDir(pinned, deps) {
11541
11690
  // src/owner.ts
11542
11691
  import { closeSync as closeSync2, openSync as openSync2, readSync, readdirSync } from "fs";
11543
11692
  import { homedir as homedir5 } from "os";
11544
- import { join as join5 } from "path";
11693
+ import { join as join6 } from "path";
11545
11694
  var HEAD_BYTES = 64e3;
11546
11695
  function readHead(file) {
11547
11696
  const fd = openSync2(file, "r");
@@ -11557,7 +11706,7 @@ function ownerSession(slot, deps = {}) {
11557
11706
  const m = /^session:([0-9a-f]{8})$/.exec(slot);
11558
11707
  if (!m) return null;
11559
11708
  const prefix = m[1];
11560
- const root = deps.projectsDir ?? join5(homedir5(), ".claude", "projects");
11709
+ const root = deps.projectsDir ?? join6(homedir5(), ".claude", "projects");
11561
11710
  const list = deps.list ?? ((dir) => readdirSync(dir));
11562
11711
  const head = deps.head ?? readHead;
11563
11712
  let folders;
@@ -11569,7 +11718,7 @@ function ownerSession(slot, deps = {}) {
11569
11718
  for (const folder of folders) {
11570
11719
  let files;
11571
11720
  try {
11572
- files = list(join5(root, folder));
11721
+ files = list(join6(root, folder));
11573
11722
  } catch {
11574
11723
  continue;
11575
11724
  }
@@ -11577,7 +11726,7 @@ function ownerSession(slot, deps = {}) {
11577
11726
  if (!file) continue;
11578
11727
  let text;
11579
11728
  try {
11580
- text = head(join5(root, folder, file));
11729
+ text = head(join6(root, folder, file));
11581
11730
  } catch {
11582
11731
  continue;
11583
11732
  }
@@ -12881,6 +13030,23 @@ var zodToJsonSchema2 = (schema, options) => {
12881
13030
  // ../../packages/schema/dist/index.js
12882
13031
  var OPTIONS_MIN2 = 2;
12883
13032
  var OPTIONS_MAX2 = 6;
13033
+ var OptionSchema2 = external_exports.object({
13034
+ id: external_exports.string(),
13035
+ label: external_exports.string(),
13036
+ hint: external_exports.string().max(500).describe("Optional short projection of consequence or action if this option is chosen (e.g. 'Reruns test suite', 'Merges to main').").optional(),
13037
+ // .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
13038
+ // the constraints below are what an agent reads when deciding to use these.
13039
+ html: external_exports.string().max(16384).describe(
13040
+ "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Rendered edge-to-edge in a responsive card that is 200pt tall (about 320pt wide on a phone, with the next option peeking beside it); make your HTML fit that viewport. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
13041
+ ).optional(),
13042
+ image: external_exports.string().url().describe(
13043
+ "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
13044
+ ).optional()
13045
+ });
13046
+ var OptionInputSchema2 = OptionSchema2.omit({ id: true }).extend({
13047
+ label: external_exports.string().trim().min(1).max(1e3)
13048
+ }).strict();
13049
+ var NIGHT2 = { from: 23, to: 7 };
12884
13050
  function draft20202(node) {
12885
13051
  if (Array.isArray(node)) return node.map(draft20202);
12886
13052
  if (node && typeof node === "object") {
@@ -12912,26 +13078,21 @@ function mcpInputSchema2(s) {
12912
13078
  }
12913
13079
  var AskInputSchema2 = external_exports.object({
12914
13080
  id: external_exports.string().optional().describe("Optional idempotency key or client-side ID for this specific ask."),
12915
- parentId: external_exports.string().uuid().optional().describe("Optional explicit parent Goal ID. If omitted, the platform's semantic router will auto-cluster it."),
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."),
12916
13082
  repo: external_exports.string().optional().describe("Optional repository context."),
12917
13083
  ask: external_exports.string().trim().min(1).max(1e4).describe(
12918
- "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."
12919
13085
  ),
12920
- options: external_exports.array(external_exports.object({
12921
- label: external_exports.string().trim().min(1).max(1e3),
12922
- hint: external_exports.string().trim().max(500).describe("Optional short projection of consequence or action if this option is chosen.").optional(),
12923
- image: external_exports.string().url().optional(),
12924
- html: external_exports.string().max(16384).describe("Optional sandboxed HTML/CSS preview. No JavaScript or network; inline CSS and data: URIs only. It renders edge-to-edge in a responsive card 200pt tall (about 320pt wide on a phone, with the next option peeking beside it), so fit the HTML to that viewport.").optional()
12925
- }).strict()).min(2).max(6).optional()
13086
+ options: external_exports.array(OptionInputSchema2).min(2).max(6).optional()
12926
13087
  }).strict();
12927
13088
  var StartContactSchema2 = external_exports.object({
12928
- asks: external_exports.array(AskInputSchema2).min(1).describe("The list of asks/questions to pose. They will be semantically clustered by the API."),
13089
+ asks: external_exports.array(AskInputSchema2).min(1).describe("The questions to pose, one per object. An ask with a parentId is filed on that Goal as it is; only an ask naming no Goal is placed in the tree by Paigy."),
12929
13090
  waiting: external_exports.enum(["none", "hard"]).default("none"),
12930
13091
  channel: external_exports.enum(["notification", "call"]).default("notification")
12931
13092
  }).strict();
12932
13093
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
12933
13094
  var CONTACT_SCHEMA2 = { type: "object", ...mcpInputSchema2(ContactSchema2) };
12934
- 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'. The platform will semantically cluster your asks under the correct Goals automatically. 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).";
12935
13096
  var CreateGoalSchema2 = external_exports.object({
12936
13097
  outcome: external_exports.string().trim().min(1).max(1e4),
12937
13098
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -12939,10 +13100,6 @@ var CreateGoalSchema2 = external_exports.object({
12939
13100
  title: external_exports.string().trim().min(1).max(80).optional(),
12940
13101
  ownerParticipant: external_exports.string().trim().min(1).optional(),
12941
13102
  idempotencyKey: external_exports.string().trim().min(1).max(200),
12942
- /** A past conversation this Goal should be read against — History's "new session from this"
12943
- * (owner, on the call of 2026-09-14: "let's do the reference with the threading"). A
12944
- * reference only: the owner reads it through `get_thread`, which does its own scoping, and
12945
- * the writer refuses a thread belonging to another account. */
12946
13103
  /** THE GOAL THIS ONE BELONGS UNDER (owner, 2026-09-15: "the ask I gave for the design doc
12947
13104
  * didn't get created as a child goal of the voice UI goal, which is how it should've
12948
13105
  * worked"). It could not have been: this door took no parent, so the only route was
@@ -12982,23 +13139,13 @@ var UpdateGoalToolSchema2 = UpdateGoalSchema2.omit({ operationId: true }).extend
12982
13139
  var ClaimGoalSchema2 = external_exports.object({ goalId: external_exports.string().uuid().optional() }).strict();
12983
13140
  var GetGoalSchema2 = external_exports.object({ goalId: external_exports.string().uuid() }).strict();
12984
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.";
12985
- 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.";
12986
- 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.";
12987
- 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.";
12988
13145
  var CheckRepliesSchema2 = external_exports.object({}).strict();
12989
- var GetThreadSchema2 = external_exports.object({
12990
- parentId: external_exports.string().describe("The Thread to read \u2014 the threadId a Delivery returned, or the parentId of a search hit.")
12991
- }).strict();
12992
- var GET_THREAD_DESCRIPTION2 = "Read the authorized durable Entries on one conversation Thread \u2014 what you wrote there and what was delivered to you, oldest first. Use claim_goal to find the work to resume; use this to rehydrate a Thread that a search hit or a Delivery named.";
12993
- var SearchThreadsSchema2 = external_exports.object({
12994
- q: external_exports.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
12995
- }).strict();
12996
- var SEARCH_THREADS_DESCRIPTION2 = `Search your PAST conversations before asking \u2014 "have we discussed this before?". Full-text over your own threads (the asks you sent + the user's answers); returns ranked threads with highlighted snippets, NOT rows: { hits: [{ parentId, at, agentLabel, matches: [{ notificationId, role, snippet }] }] }. The loop this exists for: search first \u2192 get_thread the best hit to rehydrate it \u2192 THEN continue or contact, so you answer with receipts ("last week you said ship it") instead of re-asking. Read-only, safe to call anytime; scoped to your own account's threads.`;
12997
13146
  var AGENT_TOOLS2 = [
12998
13147
  { name: "contact", description: CONTACT_DESCRIPTION2, inputSchema: CONTACT_SCHEMA2 },
12999
13148
  { name: "check_replies", description: CHECK_REPLIES_DESCRIPTION2, inputSchema: mcpInputSchema2(CheckRepliesSchema2) },
13000
- { name: "get_thread", description: GET_THREAD_DESCRIPTION2, inputSchema: mcpInputSchema2(GetThreadSchema2) },
13001
- { name: "search_threads", description: SEARCH_THREADS_DESCRIPTION2, inputSchema: mcpInputSchema2(SearchThreadsSchema2) },
13002
13149
  { name: "create_goal", description: CREATE_GOAL_DESCRIPTION2, inputSchema: mcpInputSchema2(CreateGoalToolSchema2) },
13003
13150
  { name: "claim_goal", description: CLAIM_GOAL_DESCRIPTION2, inputSchema: mcpInputSchema2(ClaimGoalSchema2) },
13004
13151
  { name: "get_goal", description: GET_GOAL_DESCRIPTION2, inputSchema: mcpInputSchema2(GetGoalSchema2) },
@@ -13047,19 +13194,6 @@ var TransformSchema2 = external_exports.enum([
13047
13194
  "summarize"
13048
13195
  // reduce volume, keep decision value — 30-turn cap, spoken briefing
13049
13196
  ]);
13050
- var OptionSchema2 = external_exports.object({
13051
- id: external_exports.string(),
13052
- label: external_exports.string(),
13053
- hint: external_exports.string().max(500).describe("Optional short projection of consequence or action if this option is chosen (e.g. 'Reruns test suite', 'Merges to main').").optional(),
13054
- // .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
13055
- // the constraints below are what an agent reads when deciding to use these.
13056
- html: external_exports.string().max(16384).describe(
13057
- "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Rendered edge-to-edge in a responsive card that is 200pt tall (about 320pt wide on a phone, with the next option peeking beside it); make your HTML fit that viewport. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
13058
- ).optional(),
13059
- image: external_exports.string().url().describe(
13060
- "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
13061
- ).optional()
13062
- });
13063
13197
  var VisualSchema2 = external_exports.object({
13064
13198
  url: external_exports.string().url(),
13065
13199
  label: external_exports.string().optional()
@@ -13113,7 +13247,7 @@ var NotifyRequestFields2 = external_exports.object({
13113
13247
  * ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
13114
13248
  * superRefine at the bottom enforces exactly one of the two. */
13115
13249
  context: ContextSchema2.optional(),
13116
- options: external_exports.array(OptionSchema2.omit({ id: true })).min(OPTIONS_MIN2).max(OPTIONS_MAX2).optional().describe(
13250
+ options: external_exports.array(OptionInputSchema2).min(OPTIONS_MIN2).max(OPTIONS_MAX2).optional().describe(
13117
13251
  "The choices, in order \u2014 required when select is 'one'/'many'/'rank', omitted otherwise. Ids are assigned automatically by position ('1', '2', \u2026); the user's answer references them as optionId(s)."
13118
13252
  ),
13119
13253
  points: external_exports.array(external_exports.string().min(1)).optional().describe(
@@ -13307,7 +13441,7 @@ var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
13307
13441
  /** The awaited ask was REPLACED by a newer notification on its thread (e.g. a
13308
13442
  * post-feedback revision, #633) — the user will never answer this id. Stop
13309
13443
  * awaiting it; the live ask is the thread's newest turn (await that one, or
13310
- * re-orient via get_thread / check_replies). */
13444
+ * re-orient via check_replies). */
13311
13445
  external_exports.object({
13312
13446
  type: external_exports.literal("superseded"),
13313
13447
  parentId: external_exports.string(),
@@ -13352,9 +13486,9 @@ var AgendaTurnSchema2 = external_exports.object({
13352
13486
  /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
13353
13487
  * the planner declares duplicates instead of asking them twice. */
13354
13488
  coveredIds: external_exports.array(external_exports.string()).optional(),
13355
- /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
13356
- * line went out on 2026-07-28 and the caller could not answer it at all. */
13357
- 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([]),
13358
13492
  question: external_exports.string().min(1).nullable(),
13359
13493
  /** True on the one turn carrying the agent's own declared question. */
13360
13494
  asks: external_exports.boolean().optional(),
@@ -13389,42 +13523,12 @@ var AgendaTurnSchema2 = external_exports.object({
13389
13523
  var CLAIM_STALE_MS2 = 30 * 6e4;
13390
13524
  var InboxItemSchema2 = external_exports.object({
13391
13525
  id: external_exports.string(),
13392
- /** The conversation thread + connection this item lives on. Present on the replied
13393
- * detail — they power History's "Continue" / "New session from this" (#57/#251). */
13394
- parentId: external_exports.string().optional(),
13395
- /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
13396
- * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
13397
- * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
13398
- * accumulates every message an agent ever sent, so grouping by it renders a day of
13399
- * unrelated updates as a single "12-part request". Absent on rows written before the
13400
- * `asks` table, and on anything that never went through `notify` — both fall back to the
13401
- * thread, which is what the client did for all rows until now. */
13402
- askId: external_exports.string().optional(),
13403
- /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
13404
- * shares one `created_at` to the microsecond, so without it the author's order is
13405
- * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
13406
- * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
13407
- * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
13408
- seq: external_exports.number().int().optional(),
13409
- /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
13410
- * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
13411
- * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
13412
- * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
13413
- * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
13414
- * server that can still see every row states it. Absent on any row with no `askId`: a
13415
- * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
13416
- * the one part of its own arrival a single row cannot answer. */
13417
- units: external_exports.number().int().positive().optional(),
13418
13526
  tokenId: external_exports.string().optional(),
13419
13527
  status: NotifyStatusSchema2,
13420
13528
  context: ContextSchema2,
13421
13529
  options: external_exports.array(OptionSchema2).optional(),
13422
13530
  /** The ask's declared coverage points (#396), when the agent sent them. */
13423
13531
  points: external_exports.array(external_exports.string()).optional(),
13424
- /** The call's AGENDA (broker/agenda-design.md): the ordered turns it is made of, built at
13425
- * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
13426
- * between them could not express a call as a sequence. `question: null` is a real turn —
13427
- * a status update stays a statement instead of being shaped into a yes/no. */
13428
13532
  /** Does this claim want an ANSWER, or is it telling you something? Written per row from
13429
13533
  * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
13430
13534
  * its acknowledge affordance: without it a status update offers a text box and a dismiss,
@@ -13445,7 +13549,31 @@ var InboxItemSchema2 = external_exports.object({
13445
13549
  * instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
13446
13550
  * `createdAt`. */
13447
13551
  agentStateAt: external_exports.string().datetime().optional(),
13448
- agenda: external_exports.array(AgendaTurnSchema2).optional(),
13552
+ /** THE QUESTIONS A CALL CARRIES — the call screen's agenda spine (walk/design.md §11, owner
13553
+ * 2026-09-22). One per DecisionNeed on the Call, in the Call's order, answered or open (a
13554
+ * superseded or cancelled need is no longer a question anyone is asked). Present only on a
13555
+ * Call's cards, and every card of that Call carries the same list: the call screen reads it
13556
+ * once, off the one read it already makes (`GET /api/inbox/:callId`).
13557
+ *
13558
+ * It counts DECISIONS, not agenda turns: turns include context-only lines and are re-planned
13559
+ * every cycle, so a spine drawn from them would change length under the caller mid-call.
13560
+ *
13561
+ * `entryId` is the request Entry — the id the bot calls a CLAIM, and the one it names on the
13562
+ * `turn` topic (`asking`, `settled`), because the bot never sees a DecisionNeed id. `title` is
13563
+ * the card's own concise heading; `answer` the accepted answer in words, null while open. It
13564
+ * REPLACED `agenda` (turns), which nothing ever filled. */
13565
+ questions: external_exports.array(external_exports.object({
13566
+ id: external_exports.string(),
13567
+ entryId: external_exports.string(),
13568
+ title: external_exports.string(),
13569
+ state: external_exports.enum(["open", "answered"]),
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()
13576
+ })).optional(),
13449
13577
  visuals: external_exports.array(VisualSchema2).optional(),
13450
13578
  /** The connected agent's name (the single pairing name — user-typed, or the
13451
13579
  * agent's suggestion, or a default silly name). */
@@ -13462,16 +13590,35 @@ var InboxItemSchema2 = external_exports.object({
13462
13590
  * agent (held in the inbox until the agent replies on the thread). */
13463
13591
  turn: external_exports.enum(["you", "agent"]).default("you"),
13464
13592
  /** Hard error reason on an awaiting request (turn="agent") — the wake failed to reach
13465
- * the agent (provider-agnostic; set server-side). Absent = no hard error, though the
13466
- * 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. */
13467
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(),
13468
13601
  clarifies: external_exports.string().optional(),
13469
- /** The ring ladder ran out while this was still pending — we tried to reach you and
13470
- * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
13471
- * agent with nothing to say, which the roster drew identically until now: "nothing to
13472
- * say" and "gave up saying it" are opposite situations wearing the same face
13473
- * (navigation-design.md, gap 1). False for anything that never rang. */
13474
- gaveUp: external_exports.boolean().default(false),
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(),
13607
+ /** THE RING, ON THE ITEM (walk/design.md §12 §17, #2251): the last ring on this card was
13608
+ * declined, and what the ladder will do next — read off the cron's own row, never computed
13609
+ * on the phone. Present only while a `declined` receipt stands on the card's last Call.
13610
+ * The ladder is ACCOUNT-WIDE (#2259): `anchorAt` and `step` are the account's position;
13611
+ * `nextRingAt` is this card's armed instant (`deliveries.next_ring_at`), null once the cron
13612
+ * has disarmed it — the curve's end, `inbox`/`dismiss`, or a setting that said no more rings.
13613
+ * It replaced `gaveUp` (deleted 2026-09-22): "the ladder spent" was a boolean the projection
13614
+ * never set, and it is `nextRingAt === null` here — the party's *Missed you* (`party/dress.ts`)
13615
+ * and the roster's `unreached` read `declinedAt`, and stand while it does. */
13616
+ ring: external_exports.object({
13617
+ declinedAt: external_exports.string().datetime(),
13618
+ anchorAt: external_exports.string().datetime(),
13619
+ nextRingAt: external_exports.string().datetime().nullable(),
13620
+ step: external_exports.number().int()
13621
+ }).optional(),
13475
13622
  /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
13476
13623
  * Absent for anything never delivered through a push, and for older rows written before
13477
13624
  * the reason was recorded. Deliberately a debug affordance, shown small (owner,
@@ -13505,7 +13652,10 @@ var InboxItemSchema2 = external_exports.object({
13505
13652
  goalIds: external_exports.array(external_exports.string()),
13506
13653
  decisionNeedId: external_exports.string().optional(),
13507
13654
  sealed: external_exports.boolean(),
13508
- 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()
13509
13659
  }).optional()
13510
13660
  });
13511
13661
  var APNS_TOKEN_RE2 = /^[0-9a-fA-F]{64}$/;
@@ -13536,6 +13686,8 @@ var MissedCallSchema2 = external_exports.enum([
13536
13686
  "inbox",
13537
13687
  "dismiss"
13538
13688
  ]);
13689
+ var clock2 = (h) => h === 0 ? "midnight" : h === 12 ? "noon" : h < 12 ? `${h} am` : `${h - 12} pm`;
13690
+ var QUIET2 = ` Nothing rings from ${clock2(NIGHT2.from)} to ${clock2(NIGHT2.to)} your time; the count waits for morning.`;
13539
13691
  var BrokerTuningSchema2 = external_exports.object({
13540
13692
  /** 'none' = skip the spoken ack after a mapped answer (power users find it slow). */
13541
13693
  ackVerbosity: external_exports.enum(["normal", "none"]).optional(),
@@ -13570,6 +13722,12 @@ var UserSettingsSchema2 = external_exports.object({
13570
13722
  * must not silently reset this privacy choice. Absent = leave unchanged on
13571
13723
  * write, 'hosted' on read (see store.ts). */
13572
13724
  voiceMode: external_exports.enum(["hosted", "on_device"]).optional(),
13725
+ /** Talk — after you answer, the next step is read aloud (walk/design.md §6). ALWAYS ON until
13726
+ * turned off (owner, 2026-09-18, #2249): a setting, not a per-walk toggle. Optional, NOT
13727
+ * defaulted, for the same reason `voiceMode` is: a stale client PATCHing the full settings
13728
+ * object must not silently turn it back on. Absent = leave unchanged on write, true on
13729
+ * read (see store.ts). */
13730
+ talk: external_exports.boolean().optional(),
13573
13731
  /** Per-user ring budget (#603): calls per rolling day before further calls
13574
13732
  * degrade to banner. Absent = the global default (25). A number, never a
13575
13733
  * bypass — every account keeps a ceiling. No UI; set per user for testing. */
@@ -13596,7 +13754,6 @@ var UserSettingsSchema2 = external_exports.object({
13596
13754
  });
13597
13755
  var HistoryItemSchema2 = external_exports.object({
13598
13756
  id: external_exports.string(),
13599
- parentId: external_exports.string(),
13600
13757
  /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
13601
13758
  initiator: external_exports.enum(["user", "agent"]),
13602
13759
  title: external_exports.string(),
@@ -13755,6 +13912,24 @@ var QueueQuestionSchema2 = external_exports.object({
13755
13912
  * gate at all. */
13756
13913
  blocked: external_exports.boolean().default(false)
13757
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
+ });
13758
13933
  var QueueItemSchema2 = external_exports.object({
13759
13934
  id: external_exports.string(),
13760
13935
  /** One-line headline — the first sentence of the outcome. */
@@ -13767,6 +13942,9 @@ var QueueItemSchema2 = external_exports.object({
13767
13942
  assignee: external_exports.string().nullable().default(null),
13768
13943
  /** What the agent last said it was doing; null if it has said nothing. */
13769
13944
  progress: external_exports.string().nullable().default(null),
13945
+ /** HOME'S LINE FOR THAT NOTE (owner, 2026-09-23): a few plain words one read wrote from `progress`,
13946
+ * served only while it was written for the current note. Null means show the Goal's name. */
13947
+ progressLine: external_exports.string().nullable().optional(),
13770
13948
  reviewPending: external_exports.boolean().default(false),
13771
13949
  dueAt: external_exports.string().nullable().default(null),
13772
13950
  /** The Goal this one was opened under; null at the root. */
@@ -13779,11 +13957,20 @@ var QueueItemSchema2 = external_exports.object({
13779
13957
  blocked: external_exports.boolean().default(false),
13780
13958
  /** Every decision need on it, open or settled — the page decides which to show. */
13781
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(),
13782
13964
  /** The repository or project identifier this Goal belongs to (#2280), null if untracked. */
13783
13965
  repo: external_exports.string().nullable().optional(),
13784
13966
  createdAt: external_exports.string(),
13785
- updatedAt: external_exports.string().nullable().default(null)
13967
+ updatedAt: external_exports.string().nullable().default(null),
13968
+ /** When its owner last SAID something about it (`goals.last_progress_at`, written by every
13969
+ * `update_goal` that changes `progress`). `updatedAt` moves for reasons nobody chose — a
13970
+ * state recomputed, a review flag — so it cannot tell work in hand from work gone quiet. */
13971
+ lastProgressAt: external_exports.string().nullable().optional()
13786
13972
  });
13973
+ var COLD_AFTER_MS2 = 3 * 24 * 60 * 60 * 1e3;
13787
13974
  var NoteSourceSchema2 = external_exports.enum(["app", "call"]);
13788
13975
  var NoteStatusSchema2 = external_exports.enum(["open", "assigned", "in_progress", "done"]);
13789
13976
  var NoteRepeatSchema2 = external_exports.enum(["once", "until_done"]);
@@ -14232,7 +14419,13 @@ function startSession(opts) {
14232
14419
  ...process.env,
14233
14420
  PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
14234
14421
  ...opts.token ? { PAIGY_TOKEN: opts.token } : {},
14235
- ...opts.resume ? { PAIGY_SESSION_ID: opts.resume } : {}
14422
+ ...opts.resume ? { PAIGY_SESSION_ID: opts.resume } : {},
14423
+ // A PUMP DRIVES THIS PROCESS (2026-09-22, #2265): every run `runHarness` starts has the
14424
+ // bridge's pump typing answers in, so nothing inside it may start a second delivery path.
14425
+ // Exported on EVERY spawn — `PAIGY_SESSION_ID` above is the conversation to resume, and a
14426
+ // fresh spawn has none, which is how a fresh run read as "not harness-started" to the
14427
+ // session-start hook and the `listen` tool (they now test either mark; listening.ts).
14428
+ PAIGY_HARNESS: "1"
14236
14429
  },
14237
14430
  stdio: ["pipe", "pipe", "pipe"]
14238
14431
  });
@@ -14439,6 +14632,7 @@ async function ask(state, rail, input) {
14439
14632
  waiting: "hard",
14440
14633
  channel: input.call ? "call" : "notification"
14441
14634
  });
14635
+ if ("recorded" in sent) return null;
14442
14636
  const entryId = sent.requestEntryIds[0];
14443
14637
  if (!entryId) return null;
14444
14638
  (state.seen ??= /* @__PURE__ */ new Set()).add(entryId);
@@ -14615,7 +14809,7 @@ function runHarness(opts) {
14615
14809
  // src/update.ts
14616
14810
  import { execFile } from "child_process";
14617
14811
  import { createRequire } from "module";
14618
- import { dirname as dirname3, join as join6 } from "path";
14812
+ import { dirname as dirname3, join as join7 } from "path";
14619
14813
  import { promisify } from "util";
14620
14814
  var SERVICE_LABEL = "ai.paigy.harness";
14621
14815
  var VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -14642,7 +14836,7 @@ async function latestVersion() {
14642
14836
  }
14643
14837
  async function install(version) {
14644
14838
  const bin = dirname3(process.execPath);
14645
- await promisify(execFile)(join6(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
14839
+ await promisify(execFile)(join7(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
14646
14840
  env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, npm_config_update_notifier: "false" },
14647
14841
  timeout: 5 * 6e4
14648
14842
  });
@@ -14693,7 +14887,7 @@ function selfUpdater(deps) {
14693
14887
  }
14694
14888
 
14695
14889
  // src/host.ts
14696
- var HOST_FILE = join7(homedir7(), ".paigy", "host.json");
14890
+ var HOST_FILE = join8(homedir7(), ".paigy", "host.json");
14697
14891
  function readHostState() {
14698
14892
  try {
14699
14893
  const s = JSON.parse(readFileSync4(HOST_FILE, "utf8"));
@@ -14722,9 +14916,9 @@ async function slotHasWaitingWork(token, session) {
14722
14916
  }
14723
14917
  var PRESENCE_FRESH_MS = 3 * 6e4;
14724
14918
  function wakeAction(live, now, serverSeenAt) {
14725
- if (live) return "skip";
14726
- if (serverSeenAt !== void 0 && now - serverSeenAt < PRESENCE_FRESH_MS) return "skip";
14727
- return "spawn";
14919
+ if (live) return { action: "skip", why: "run" };
14920
+ if (serverSeenAt !== void 0 && now - serverSeenAt < PRESENCE_FRESH_MS) return { action: "skip", why: "presence" };
14921
+ return { action: "spawn", why: "nobody" };
14728
14922
  }
14729
14923
  function startHost(opts) {
14730
14924
  const runs = /* @__PURE__ */ new Map();
@@ -14814,7 +15008,11 @@ function startHost(opts) {
14814
15008
  const live = runs.get(key);
14815
15009
  const tokenId = slotIdentity(slot).tokenId;
14816
15010
  const seenByServer = tokenId ? presence.get(tokenId) : void 0;
14817
- if (wakeAction(!!live, Date.now(), seenByServer) === "skip") return;
15011
+ const decided = wakeAction(!!live, Date.now(), seenByServer);
15012
+ if (decided.action === "skip") {
15013
+ opts.log(decided.why === "run" ? `\u25CC ${slotName(slot) ?? slot} is running here \u2014 its pump types the work in; not spawning` : `\u25CC ${slotName(slot) ?? slot} is working somewhere this host cannot see (heartbeat) \u2014 not spawning`);
15014
+ return;
15015
+ }
14818
15016
  const token = readToken(slot);
14819
15017
  if (!token) return;
14820
15018
  const owner = ownerSession(slot);
@@ -15194,9 +15392,9 @@ async function available(deps = {}) {
15194
15392
  });
15195
15393
  return [ollama, ...harnesses];
15196
15394
  }
15197
- function refusal(options, asked) {
15395
+ function refusal(options, asked2) {
15198
15396
  const lines2 = [
15199
- asked ? `Can't judge with --via ${asked}: ${options.find((o) => o.provider === asked)?.hint ?? "it isn't available on this machine"}` : "No local judge available \u2014 triage refuses rather than sending your queue to a cloud model.",
15397
+ asked2 ? `Can't judge with --via ${asked2}: ${options.find((o) => o.provider === asked2)?.hint ?? "it isn't available on this machine"}` : "No local judge available \u2014 triage refuses rather than sending your queue to a cloud model.",
15200
15398
  "",
15201
15399
  "Triage runs the model where your queue already lives (#1106). Your options:"
15202
15400
  ];
@@ -15220,10 +15418,10 @@ var RUNNERS = {
15220
15418
  agy: { cli: "agy", argv: () => ["-p"] }
15221
15419
  };
15222
15420
  var RUN_TIMEOUT_MS = 5 * 6e4;
15223
- async function judgeFor(asked, deps = {}) {
15421
+ async function judgeFor(asked2, deps = {}) {
15224
15422
  const options = await available(deps);
15225
- const chosen = asked ? options.find((o) => o.provider === asked) : options.find((o) => o.ready);
15226
- if (!chosen?.ready) return { error: refusal(options, asked) };
15423
+ const chosen = asked2 ? options.find((o) => o.provider === asked2) : options.find((o) => o.ready);
15424
+ if (!chosen?.ready) return { error: refusal(options, asked2) };
15227
15425
  if (chosen.provider === "ollama") {
15228
15426
  const model = chosen.model;
15229
15427
  return {
@@ -15244,9 +15442,9 @@ async function judgeFor(asked, deps = {}) {
15244
15442
  };
15245
15443
  }
15246
15444
  const runner = RUNNERS[chosen.provider];
15247
- if (!runner) return { error: refusal(options, asked) };
15445
+ if (!runner) return { error: refusal(options, asked2) };
15248
15446
  const bin = which(runner.cli);
15249
- if (!bin) return { error: refusal(options, asked) };
15447
+ if (!bin) return { error: refusal(options, asked2) };
15250
15448
  return {
15251
15449
  provider: chosen.provider,
15252
15450
  model: null,
@@ -15552,7 +15750,7 @@ var win = null;
15552
15750
  function log(message) {
15553
15751
  win?.webContents.send("paigy:log", message);
15554
15752
  }
15555
- var ICON = join8(here, "..", "assets", "icon.png");
15753
+ var ICON = join9(here, "..", "assets", "icon.png");
15556
15754
  function createWindow() {
15557
15755
  win = new BrowserWindow({
15558
15756
  // Sized for the working log, which is now the window's centerpiece; the layout is a
@@ -15564,9 +15762,9 @@ function createWindow() {
15564
15762
  minHeight: 520,
15565
15763
  icon: ICON,
15566
15764
  // Windows/Linux window icon; macOS uses the dock icon below
15567
- webPreferences: { preload: join8(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
15765
+ webPreferences: { preload: join9(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
15568
15766
  });
15569
- void win.loadFile(join8(here, "..", "renderer", "index.html"));
15767
+ void win.loadFile(join9(here, "..", "renderer", "index.html"));
15570
15768
  }
15571
15769
  var host = null;
15572
15770
  app.whenReady().then(() => {
@@ -15635,7 +15833,7 @@ ipcMain.handle("paigy:agent", async (_e, name) => {
15635
15833
  });
15636
15834
  ipcMain.handle("paigy:agent-log", (_e, name) => {
15637
15835
  try {
15638
- const all = readFileSync5(join8(homedir9(), ".paigy", "host.log"), "utf8").split("\n");
15836
+ const all = readFileSync5(join9(homedir9(), ".paigy", "host.log"), "utf8").split("\n");
15639
15837
  const mine = all.filter((l) => l.includes(`[${name}]`) || /[▶✖◉⚠]/.test(l) && l.includes(name));
15640
15838
  return mine.slice(-500).join("\n");
15641
15839
  } catch {