@paigy/harness 0.3.30 → 0.3.31

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 +224 -27
  2. package/dist/main.js +224 -27
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -23858,6 +23858,22 @@ async function claimSessions(opts = {}) {
23858
23858
  if (!res.ok) throw new Error(`claim_sessions failed: ${res.status}`);
23859
23859
  return (await res.json()).sessions;
23860
23860
  }
23861
+ async function claimSessionEnds(opts = {}) {
23862
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/ends/claim`, {
23863
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
23864
+ // the HOST's identity
23865
+ }));
23866
+ if (!res.ok) throw new Error(`claim_session_ends failed: ${res.status}`);
23867
+ return (await res.json()).ends ?? [];
23868
+ }
23869
+ async function finishSessionEnd(endId, result, opts = {}) {
23870
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/ends/${encodeURIComponent(endId)}`, {
23871
+ method: "POST",
23872
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
23873
+ body: JSON.stringify({ result })
23874
+ }));
23875
+ if (!res.ok) await fail("finish_session_end", res);
23876
+ }
23861
23877
  async function recordDecision(decision, opts = {}) {
23862
23878
  const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/host/decisions`, {
23863
23879
  method: "POST",
@@ -23964,7 +23980,9 @@ async function contact(input, opts = {}) {
23964
23980
  // this cannot see.
23965
23981
  repo: repoFromRemote(a.repo) ?? a.repo ?? currentRepo() ?? void 0,
23966
23982
  ask: a.ask,
23967
- options: a.options ?? []
23983
+ options: a.options ?? [],
23984
+ ...a.select ? { select: a.select } : {},
23985
+ ...a.answers ? { answers: a.answers } : {}
23968
23986
  }));
23969
23987
  const res = await ensureAuthed(await send22(`${BACKEND_URL}/api/goals/contact`, {
23970
23988
  method: "POST",
@@ -24601,7 +24619,20 @@ var init_dist = __esm({
24601
24619
  ask: external_exports.string().trim().min(1).max(1e4).describe(
24602
24620
  "ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 each question a Question on the one Goal the ask lands on, never a Goal of its own \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; ANY contact for a person already on a call JOINS that call, whatever channel you asked for, so several arrive as one call."
24603
24621
  ),
24604
- options: external_exports.array(OptionInputSchema).min(1).max(6).optional()
24622
+ options: external_exports.array(OptionInputSchema).min(1).max(6).optional(),
24623
+ // A CHECKLIST WHEN THE CHOICES ARE INDEPENDENT (owner, 2026-10-02 01:44 UTC: "bring back the
24624
+ // checklist mode of answering"). The answer shape was taken off this surface with the wire form
24625
+ // (#575) on the promise that Paigy would derive it, and the Goal-scoped contact that replaced it
24626
+ // only ever sends pick-one or words (`goal/intake.ts` `answerOf`): three independent fixes went out
24627
+ // as "All three / 1 and 3 only / Just log it", which the person could not read without the text and
24628
+ // could not answer as the ticks he wanted. "many" is the one shape an agent knows and the read does
24629
+ // not: whether its own options exclude each other.
24630
+ select: external_exports.enum(["one", "many"]).optional().describe(
24631
+ 'How the options are answered: "one" (default) to pick one, "many" to tick any number of them. Use "many" when the options are independent things they may want several of \u2014 never combinations of them like "all three" or "1 and 3". Ignored without options.'
24632
+ ),
24633
+ answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
24634
+ "Your reply answers a question the person asked you: the id the conversation shows for it (8 characters or whole). Their question closes with this reply as its answer, and any decision of yours it was holding back goes back to them. Needs parentId: the Goal the question is on. The reply is sent to them as it is; to also ask something new, send that as its own ask."
24635
+ )
24605
24636
  }).strict();
24606
24637
  StartContactSchema = external_exports.object({
24607
24638
  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."),
@@ -24610,7 +24641,7 @@ var init_dist = __esm({
24610
24641
  }).strict();
24611
24642
  ContactSchema = external_exports.union([StartContactSchema, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
24612
24643
  CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
24613
- CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
24644
+ CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'select', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. Each option's label must stand on its own \u2014 the person may see only the labels \u2014 so never a label that points into your text ('All three', 'Option 2', '1 and 3 only'); when the options are independent and they may want several, pass select:'many' and they get a checklist. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
24614
24645
  CreateGoalSchema = external_exports.object({
24615
24646
  outcome: external_exports.string().trim().min(1).max(1e4),
24616
24647
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -24669,8 +24700,8 @@ var init_dist = __esm({
24669
24700
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
24670
24701
  diagnose: external_exports.boolean().optional()
24671
24702
  }).strict();
24672
- GET_GOAL_DESCRIPTION = "Read one Goal without claiming it: its outcome, state, revision, progress, blockers, the conversation on it (each question with its options and what was decided), `next`, the one step to take, and `more`, where to read further. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
24673
- UPDATE_GOAL_DESCRIPTION = `Update an owned Goal at an exact revision. State, ownership, dependencies, children, progress, title, and review acknowledgement are explicit; stale revisions are rejected. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
24703
+ GET_GOAL_DESCRIPTION = "Read one Goal without claiming it: its outcome, state, revision, progress, blockers, the conversation on it (each question with its options and what was decided), `next`, the one step to take, and `more`, where to read further. A question in that conversation reads `open` (nothing yet), `answered` (a choice was made, and `answer` carries it), `replied` (they said something and the read settled the question on their words \u2014 NO option of yours was chosen, and the words are the reply line beside it, so read that before you act), or `closed`. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
24704
+ 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. A CONTACT ON THIS GOAL MOVES ITS REVISION: a question filed on a Goal is a change to it, so an update prepared before a contact and sent after it is refused as stale (409 goal_revision_conflict) \u2014 re-read the Goal, then write. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
24674
24705
  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.";
24675
24706
  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.";
24676
24707
  CheckRepliesSchema = external_exports.object({}).strict();
@@ -25237,12 +25268,13 @@ var init_dist = __esm({
25237
25268
  * they are an enum on a live column across every account, and the rename is a rename of
25238
25269
  * what people read (owner, 2026-09-30). */
25239
25270
  sessionMode: external_exports.enum(["default", "all_calls", "silent"]),
25240
- /** DEAD, AND ACCEPTED ANYWAY (#2813). DateNight is inbox-only with no way back, so nothing
25241
- * reads this. It stays OPTIONAL rather than deleted because a phone on the old bundle
25242
- * PATCHes the whole settings object and would be refused for sending a key we stopped
25243
- * wanting — the same trap `voiceMode` below already documents. #2813 drops the field and
25244
- * its column once no installed build still sends it. */
25245
- silentPush: external_exports.boolean().optional(),
25271
+ /** `silentPush` lived here until #2813 and is now GONE, field and column both. It was kept as an
25272
+ * optional long after DateNight stopped reading it, on the theory that a phone on an older
25273
+ * bundle PATCHing the whole settings object would be REFUSED for sending a key we had stopped
25274
+ * wanting. That theory was wrong about this schema: these are plain `z.object`s with no
25275
+ * `.strict()` anywhere in this file, and zod STRIPS unknown keys rather than rejecting them, so
25276
+ * an old bundle's `silentPush` is accepted and ignored. Worth remembering before keeping the
25277
+ * next dead field for the same reason. */
25246
25278
  /** Opt-in (default false) to using your content to improve Paigy and train models. */
25247
25279
  improveConsent: external_exports.boolean(),
25248
25280
  missedCall: MissedCallSchema.default("backoff_standard"),
@@ -25376,7 +25408,8 @@ var init_dist = __esm({
25376
25408
  });
25377
25409
  LedgerItemSchema = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
25378
25410
  AgentLedgerSchema = external_exports.object({
25379
- agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
25411
+ /** Null when the agent has not named itself yet — never a placeholder (owner, 2026-10-01). */
25412
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string().nullable(), revokedAt: external_exports.string().nullable() }),
25380
25413
  /** Its own questions you have not answered. */
25381
25414
  asks: external_exports.array(LedgerItemSchema),
25382
25415
  /** Its questions you answered that nobody acted on — still owed to somebody. */
@@ -25543,7 +25576,7 @@ var init_dist = __esm({
25543
25576
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
25544
25577
  * answer to a question, a voice note filed as work. Null when the only hands on it have been its
25545
25578
  * agent's; optional, so a hand-built queue (fixtures, the demo) and a door older than
25546
- * 20260930222356 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
25579
+ * 20261001030911 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
25547
25580
  * Work tab orders as it did before the migration rather than throwing on the missing key.
25548
25581
  *
25549
25582
  * It is a FACT, not a reconstruction: `latest` holds one Entry, so an agent's progress note a
@@ -25860,7 +25893,9 @@ var init_dist = __esm({
25860
25893
  * summarized like a pre-made option is"), so the row scans like a chosen option and their
25861
25894
  * own words stay under it. Absent on every line stored before the read wrote them, and on
25862
25895
  * anything that is not an answer. */
25863
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional() }))
25896
+ /** `about` is the request the line answered (its question), null for words that answered none —
25897
+ * the key the screen groups on, so one question is one row however many times it was answered. */
25898
+ lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
25864
25899
  })),
25865
25900
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
25866
25901
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
@@ -32981,7 +33016,20 @@ var AskInputSchema2 = external_exports.object({
32981
33016
  ask: external_exports.string().trim().min(1).max(1e4).describe(
32982
33017
  "ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 each question a Question on the one Goal the ask lands on, never a Goal of its own \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; ANY contact for a person already on a call JOINS that call, whatever channel you asked for, so several arrive as one call."
32983
33018
  ),
32984
- options: external_exports.array(OptionInputSchema2).min(1).max(6).optional()
33019
+ options: external_exports.array(OptionInputSchema2).min(1).max(6).optional(),
33020
+ // A CHECKLIST WHEN THE CHOICES ARE INDEPENDENT (owner, 2026-10-02 01:44 UTC: "bring back the
33021
+ // checklist mode of answering"). The answer shape was taken off this surface with the wire form
33022
+ // (#575) on the promise that Paigy would derive it, and the Goal-scoped contact that replaced it
33023
+ // only ever sends pick-one or words (`goal/intake.ts` `answerOf`): three independent fixes went out
33024
+ // as "All three / 1 and 3 only / Just log it", which the person could not read without the text and
33025
+ // could not answer as the ticks he wanted. "many" is the one shape an agent knows and the read does
33026
+ // not: whether its own options exclude each other.
33027
+ select: external_exports.enum(["one", "many"]).optional().describe(
33028
+ 'How the options are answered: "one" (default) to pick one, "many" to tick any number of them. Use "many" when the options are independent things they may want several of \u2014 never combinations of them like "all three" or "1 and 3". Ignored without options.'
33029
+ ),
33030
+ answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
33031
+ "Your reply answers a question the person asked you: the id the conversation shows for it (8 characters or whole). Their question closes with this reply as its answer, and any decision of yours it was holding back goes back to them. Needs parentId: the Goal the question is on. The reply is sent to them as it is; to also ask something new, send that as its own ask."
33032
+ )
32985
33033
  }).strict();
32986
33034
  var StartContactSchema2 = external_exports.object({
32987
33035
  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."),
@@ -32990,7 +33038,7 @@ var StartContactSchema2 = external_exports.object({
32990
33038
  }).strict();
32991
33039
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
32992
33040
  var CONTACT_SCHEMA2 = { type: "object", ...mcpInputSchema2(ContactSchema2) };
32993
- 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, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
33041
+ var CONTACT_DESCRIPTION2 = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'select', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. Each option's label must stand on its own \u2014 the person may see only the labels \u2014 so never a label that points into your text ('All three', 'Option 2', '1 and 3 only'); when the options are independent and they may want several, pass select:'many' and they get a checklist. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
32994
33042
  var CreateGoalSchema2 = external_exports.object({
32995
33043
  outcome: external_exports.string().trim().min(1).max(1e4),
32996
33044
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -33049,8 +33097,8 @@ var GetGoalSchema2 = external_exports.object({
33049
33097
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
33050
33098
  diagnose: external_exports.boolean().optional()
33051
33099
  }).strict();
33052
- 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), `next`, the one step to take, and `more`, where to read further. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
33053
- 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. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
33100
+ 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), `next`, the one step to take, and `more`, where to read further. A question in that conversation reads `open` (nothing yet), `answered` (a choice was made, and `answer` carries it), `replied` (they said something and the read settled the question on their words \u2014 NO option of yours was chosen, and the words are the reply line beside it, so read that before you act), or `closed`. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
33101
+ 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. A CONTACT ON THIS GOAL MOVES ITS REVISION: a question filed on a Goal is a change to it, so an update prepared before a contact and sent after it is refused as stale (409 goal_revision_conflict) \u2014 re-read the Goal, then write. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
33054
33102
  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.";
33055
33103
  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.";
33056
33104
  var CheckRepliesSchema2 = external_exports.object({}).strict();
@@ -33632,12 +33680,13 @@ var UserSettingsSchema2 = external_exports.object({
33632
33680
  * they are an enum on a live column across every account, and the rename is a rename of
33633
33681
  * what people read (owner, 2026-09-30). */
33634
33682
  sessionMode: external_exports.enum(["default", "all_calls", "silent"]),
33635
- /** DEAD, AND ACCEPTED ANYWAY (#2813). DateNight is inbox-only with no way back, so nothing
33636
- * reads this. It stays OPTIONAL rather than deleted because a phone on the old bundle
33637
- * PATCHes the whole settings object and would be refused for sending a key we stopped
33638
- * wanting — the same trap `voiceMode` below already documents. #2813 drops the field and
33639
- * its column once no installed build still sends it. */
33640
- silentPush: external_exports.boolean().optional(),
33683
+ /** `silentPush` lived here until #2813 and is now GONE, field and column both. It was kept as an
33684
+ * optional long after DateNight stopped reading it, on the theory that a phone on an older
33685
+ * bundle PATCHing the whole settings object would be REFUSED for sending a key we had stopped
33686
+ * wanting. That theory was wrong about this schema: these are plain `z.object`s with no
33687
+ * `.strict()` anywhere in this file, and zod STRIPS unknown keys rather than rejecting them, so
33688
+ * an old bundle's `silentPush` is accepted and ignored. Worth remembering before keeping the
33689
+ * next dead field for the same reason. */
33641
33690
  /** Opt-in (default false) to using your content to improve Paigy and train models. */
33642
33691
  improveConsent: external_exports.boolean(),
33643
33692
  missedCall: MissedCallSchema2.default("backoff_standard"),
@@ -33771,7 +33820,8 @@ var ConnectionSummarySchema2 = external_exports.object({
33771
33820
  });
33772
33821
  var LedgerItemSchema2 = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
33773
33822
  var AgentLedgerSchema2 = external_exports.object({
33774
- agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
33823
+ /** Null when the agent has not named itself yet — never a placeholder (owner, 2026-10-01). */
33824
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string().nullable(), revokedAt: external_exports.string().nullable() }),
33775
33825
  /** Its own questions you have not answered. */
33776
33826
  asks: external_exports.array(LedgerItemSchema2),
33777
33827
  /** Its questions you answered that nobody acted on — still owed to somebody. */
@@ -33938,7 +33988,7 @@ var QueueItemSchema2 = external_exports.object({
33938
33988
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
33939
33989
  * answer to a question, a voice note filed as work. Null when the only hands on it have been its
33940
33990
  * agent's; optional, so a hand-built queue (fixtures, the demo) and a door older than
33941
- * 20260930222356 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
33991
+ * 20261001030911 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
33942
33992
  * Work tab orders as it did before the migration rather than throwing on the missing key.
33943
33993
  *
33944
33994
  * It is a FACT, not a reconstruction: `latest` holds one Entry, so an agent's progress note a
@@ -34253,7 +34303,9 @@ var CallRecapSchema2 = external_exports.object({
34253
34303
  * summarized like a pre-made option is"), so the row scans like a chosen option and their
34254
34304
  * own words stay under it. Absent on every line stored before the read wrote them, and on
34255
34305
  * anything that is not an answer. */
34256
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional() }))
34306
+ /** `about` is the request the line answered (its question), null for words that answered none —
34307
+ * the key the screen groups on, so one question is one row however many times it was answered. */
34308
+ lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
34257
34309
  })),
34258
34310
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
34259
34311
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
@@ -35093,6 +35145,59 @@ function holds(pid, procStart, deps) {
35093
35145
  }
35094
35146
  return false;
35095
35147
  }
35148
+ function proven(pid, procStart, deps) {
35149
+ if (!deps.alive(pid) || typeof procStart !== "string" || !procStart.trim()) return false;
35150
+ const want = squash(procStart);
35151
+ return ["UTC", "local"].some((zone) => {
35152
+ const got = deps.started(pid, zone);
35153
+ return got !== null && squash(got) === want;
35154
+ });
35155
+ }
35156
+ function localHolderPids(deps = {}) {
35157
+ const dir = deps.dir ?? join6(process.env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir7(), ".claude"), "sessions");
35158
+ const list = deps.list ?? ((d) => readdirSync3(d));
35159
+ const read = deps.read ?? ((f) => readFileSync4(f, "utf8"));
35160
+ const check = { alive: deps.alive ?? isAlive, started: deps.started ?? psStart };
35161
+ const held = /* @__PURE__ */ new Map();
35162
+ let files;
35163
+ try {
35164
+ files = list(dir);
35165
+ } catch {
35166
+ return held;
35167
+ }
35168
+ for (const file of files) {
35169
+ if (!/^\d+\.json$/.test(file)) continue;
35170
+ let row;
35171
+ try {
35172
+ row = JSON.parse(read(join6(dir, file)));
35173
+ } catch {
35174
+ continue;
35175
+ }
35176
+ const pid = typeof row.pid === "number" ? row.pid : Number(file.slice(0, -5));
35177
+ if (typeof row.sessionId !== "string" || !row.sessionId.trim() || !Number.isInteger(pid) || pid <= 0) continue;
35178
+ if (!holds(pid, row.procStart, check)) continue;
35179
+ const slot = sessionSlot(row.sessionId);
35180
+ held.set(slot, [...held.get(slot) ?? [], { pid, proven: proven(pid, row.procStart, check) }]);
35181
+ }
35182
+ return held;
35183
+ }
35184
+ function psParent(pid) {
35185
+ try {
35186
+ const out = execFileSync3("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", timeout: 2e3 }).trim();
35187
+ const n = Number(out);
35188
+ return Number.isInteger(n) && n > 0 ? n : null;
35189
+ } catch {
35190
+ return null;
35191
+ }
35192
+ }
35193
+ function descendsFrom(pid, ancestor, parent = psParent) {
35194
+ let at = pid;
35195
+ for (let i = 0; i < 32 && at !== null && at > 1; i += 1) {
35196
+ if (at === ancestor) return true;
35197
+ at = parent(at);
35198
+ }
35199
+ return false;
35200
+ }
35096
35201
  function localHolders(deps = {}) {
35097
35202
  const dir = deps.dir ?? join6(process.env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir7(), ".claude"), "sessions");
35098
35203
  const list = deps.list ?? ((d) => readdirSync3(d));
@@ -35122,6 +35227,65 @@ function localHolders(deps = {}) {
35122
35227
  return held;
35123
35228
  }
35124
35229
 
35230
+ // src/end.ts
35231
+ var GRACE_MS = 5e3;
35232
+ async function endTerminal(pid, slot, deps) {
35233
+ try {
35234
+ deps.kill(pid, "SIGTERM");
35235
+ } catch (e) {
35236
+ return { ok: false, kind: "terminal", pid, why: `SIGTERM refused: ${e.message}`.slice(0, 200) };
35237
+ }
35238
+ const grace = deps.graceMs ?? GRACE_MS;
35239
+ for (let waited = 0; waited < grace; waited += 250) {
35240
+ if (!deps.alive(pid)) {
35241
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, pid, kind: "terminal", signal: "SIGTERM" })}`);
35242
+ return { ok: true, kind: "terminal", pid, signal: "SIGTERM" };
35243
+ }
35244
+ await deps.sleep(250);
35245
+ }
35246
+ try {
35247
+ deps.kill(pid, "SIGKILL");
35248
+ } catch (e) {
35249
+ return { ok: false, kind: "terminal", pid, why: `SIGKILL refused: ${e.message}`.slice(0, 200) };
35250
+ }
35251
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, pid, kind: "terminal", signal: "SIGKILL" })}`);
35252
+ return { ok: true, kind: "terminal", pid, signal: "SIGKILL" };
35253
+ }
35254
+ async function endSession(end, deps) {
35255
+ const slots = deps.slotsFor(end.agent);
35256
+ if (!slots.length) return { ok: false, why: "not_on_this_host" };
35257
+ let ended2 = null;
35258
+ if (end.which === "harness" || end.which === "any") {
35259
+ for (const slot of slots) {
35260
+ const run = deps.ownRun(slot);
35261
+ if (!run) continue;
35262
+ run.stop();
35263
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, kind: "harness", signal: "stop" })}`);
35264
+ ended2 = { ok: true, kind: "harness", signal: "stop" };
35265
+ }
35266
+ if (end.which === "harness" && !ended2) return { ok: false, kind: "harness", why: "no_harness_copy_here" };
35267
+ }
35268
+ if (end.which === "terminal" || end.which === "any") {
35269
+ let terminal = null;
35270
+ let unproven = null;
35271
+ for (const slot of slots) {
35272
+ for (const { pid, proven: proven2 } of deps.pidsFor(slot).filter((p) => !deps.isOwn(p.pid))) {
35273
+ if (!proven2) {
35274
+ deps.log(`session_end_refused ${JSON.stringify({ sessionId: slot, pid, why: "no_proc_start" })}`);
35275
+ unproven = { ok: false, kind: "terminal", pid, why: "no_proc_start" };
35276
+ continue;
35277
+ }
35278
+ terminal = await endTerminal(pid, slot, deps);
35279
+ if (!terminal.ok) return terminal;
35280
+ }
35281
+ }
35282
+ if (terminal) return terminal;
35283
+ if (unproven) return unproven;
35284
+ if (end.which === "terminal") return { ok: false, kind: "terminal", why: "no_terminal_copy_here" };
35285
+ }
35286
+ return ended2 ?? { ok: false, why: "nothing_running_here" };
35287
+ }
35288
+
35125
35289
  // src/owner.ts
35126
35290
  import { closeSync as closeSync2, openSync as openSync2, readSync, readdirSync as readdirSync4 } from "fs";
35127
35291
  import { homedir as homedir8 } from "os";
@@ -35425,6 +35589,31 @@ function startHost(opts) {
35425
35589
  void refreshIdentities();
35426
35590
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
35427
35591
  }
35592
+ const ends = await claimSessionEnds(asHost).catch(() => []);
35593
+ for (const end of ends) {
35594
+ const result = await endSession(end, {
35595
+ slotsFor: (agent2) => listSlots().filter((slot) => slot !== "Desktop" && slotIdentity(slot).tokenId === agent2),
35596
+ pidsFor: (slot) => localHolderPids().get(slot) ?? [],
35597
+ ownRun: (slot) => runs.get(runKey(slot))?.run ?? null,
35598
+ isOwn: (pid) => descendsFrom(pid, process.pid),
35599
+ kill: (pid, signal) => {
35600
+ process.kill(pid, signal);
35601
+ },
35602
+ alive: (pid) => {
35603
+ try {
35604
+ process.kill(pid, 0);
35605
+ return true;
35606
+ } catch (e) {
35607
+ return e.code === "EPERM";
35608
+ }
35609
+ },
35610
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
35611
+ log: opts.log
35612
+ }).catch((e) => ({ ok: false, why: `end_threw: ${e.message}`.slice(0, 200) }));
35613
+ opts.log(result.ok ? `\u25A0 ended ${end.which} copy of ${end.agent.slice(0, 8)}` : `\u25CC end of ${end.agent.slice(0, 8)} not done here: ${result.why}`);
35614
+ await finishSessionEnd(end.endId, result, asHost).catch(() => {
35615
+ });
35616
+ }
35428
35617
  }
35429
35618
  async function presenceMap() {
35430
35619
  const presence = /* @__PURE__ */ new Map();
@@ -35449,6 +35638,14 @@ function startHost(opts) {
35449
35638
  const key = runKey(slot);
35450
35639
  if (slot === "Desktop" || isRevoked(slot)) return;
35451
35640
  const live = runs.get(key);
35641
+ if (live) {
35642
+ const theirs = (localHolderPids().get(slot) ?? []).map((p) => p.pid).filter((pid) => !descendsFrom(pid, process.pid));
35643
+ if (theirs.length) {
35644
+ opts.log(`session_stood_back ${JSON.stringify({ sessionId: slot, livePid: theirs[0] })} \u2014 a terminal runs this conversation; stopping this host's copy`);
35645
+ live.run.stop();
35646
+ return;
35647
+ }
35648
+ }
35452
35649
  const tokenId = slotIdentity(slot).tokenId;
35453
35650
  const seenByServer = tokenId ? presence.get(tokenId) : void 0;
35454
35651
  const holder = holders.get(slot);
package/dist/main.js CHANGED
@@ -9975,7 +9975,20 @@ var AskInputSchema = external_exports.object({
9975
9975
  ask: external_exports.string().trim().min(1).max(1e4).describe(
9976
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 each question a Question on the one Goal the ask lands on, never a Goal of its own \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; ANY contact for a person already on a call JOINS that call, whatever channel you asked for, so several arrive as one call."
9977
9977
  ),
9978
- options: external_exports.array(OptionInputSchema).min(1).max(6).optional()
9978
+ options: external_exports.array(OptionInputSchema).min(1).max(6).optional(),
9979
+ // A CHECKLIST WHEN THE CHOICES ARE INDEPENDENT (owner, 2026-10-02 01:44 UTC: "bring back the
9980
+ // checklist mode of answering"). The answer shape was taken off this surface with the wire form
9981
+ // (#575) on the promise that Paigy would derive it, and the Goal-scoped contact that replaced it
9982
+ // only ever sends pick-one or words (`goal/intake.ts` `answerOf`): three independent fixes went out
9983
+ // as "All three / 1 and 3 only / Just log it", which the person could not read without the text and
9984
+ // could not answer as the ticks he wanted. "many" is the one shape an agent knows and the read does
9985
+ // not: whether its own options exclude each other.
9986
+ select: external_exports.enum(["one", "many"]).optional().describe(
9987
+ 'How the options are answered: "one" (default) to pick one, "many" to tick any number of them. Use "many" when the options are independent things they may want several of \u2014 never combinations of them like "all three" or "1 and 3". Ignored without options.'
9988
+ ),
9989
+ answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
9990
+ "Your reply answers a question the person asked you: the id the conversation shows for it (8 characters or whole). Their question closes with this reply as its answer, and any decision of yours it was holding back goes back to them. Needs parentId: the Goal the question is on. The reply is sent to them as it is; to also ask something new, send that as its own ask."
9991
+ )
9979
9992
  }).strict();
9980
9993
  var StartContactSchema = external_exports.object({
9981
9994
  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."),
@@ -9984,7 +9997,7 @@ var StartContactSchema = external_exports.object({
9984
9997
  }).strict();
9985
9998
  var ContactSchema = external_exports.union([StartContactSchema, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
9986
9999
  var CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
9987
- var CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
10000
+ var CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'select', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. Each option's label must stand on its own \u2014 the person may see only the labels \u2014 so never a label that points into your text ('All three', 'Option 2', '1 and 3 only'); when the options are independent and they may want several, pass select:'many' and they get a checklist. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
9988
10001
  var CreateGoalSchema = external_exports.object({
9989
10002
  outcome: external_exports.string().trim().min(1).max(1e4),
9990
10003
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -10043,8 +10056,8 @@ var GetGoalSchema = external_exports.object({
10043
10056
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
10044
10057
  diagnose: external_exports.boolean().optional()
10045
10058
  }).strict();
10046
- var GET_GOAL_DESCRIPTION = "Read one Goal without claiming it: its outcome, state, revision, progress, blockers, the conversation on it (each question with its options and what was decided), `next`, the one step to take, and `more`, where to read further. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
10047
- var UPDATE_GOAL_DESCRIPTION = `Update an owned Goal at an exact revision. State, ownership, dependencies, children, progress, title, and review acknowledgement are explicit; stale revisions are rejected. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
10059
+ var GET_GOAL_DESCRIPTION = "Read one Goal without claiming it: its outcome, state, revision, progress, blockers, the conversation on it (each question with its options and what was decided), `next`, the one step to take, and `more`, where to read further. A question in that conversation reads `open` (nothing yet), `answered` (a choice was made, and `answer` carries it), `replied` (they said something and the read settled the question on their words \u2014 NO option of yours was chosen, and the words are the reply line beside it, so read that before you act), or `closed`. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
10060
+ 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. A CONTACT ON THIS GOAL MOVES ITS REVISION: a question filed on a Goal is a change to it, so an update prepared before a contact and sent after it is refused as stale (409 goal_revision_conflict) \u2014 re-read the Goal, then write. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
10048
10061
  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.";
10049
10062
  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.";
10050
10063
  var CheckRepliesSchema = external_exports.object({}).strict();
@@ -10611,12 +10624,13 @@ var UserSettingsSchema = external_exports.object({
10611
10624
  * they are an enum on a live column across every account, and the rename is a rename of
10612
10625
  * what people read (owner, 2026-09-30). */
10613
10626
  sessionMode: external_exports.enum(["default", "all_calls", "silent"]),
10614
- /** DEAD, AND ACCEPTED ANYWAY (#2813). DateNight is inbox-only with no way back, so nothing
10615
- * reads this. It stays OPTIONAL rather than deleted because a phone on the old bundle
10616
- * PATCHes the whole settings object and would be refused for sending a key we stopped
10617
- * wanting — the same trap `voiceMode` below already documents. #2813 drops the field and
10618
- * its column once no installed build still sends it. */
10619
- silentPush: external_exports.boolean().optional(),
10627
+ /** `silentPush` lived here until #2813 and is now GONE, field and column both. It was kept as an
10628
+ * optional long after DateNight stopped reading it, on the theory that a phone on an older
10629
+ * bundle PATCHing the whole settings object would be REFUSED for sending a key we had stopped
10630
+ * wanting. That theory was wrong about this schema: these are plain `z.object`s with no
10631
+ * `.strict()` anywhere in this file, and zod STRIPS unknown keys rather than rejecting them, so
10632
+ * an old bundle's `silentPush` is accepted and ignored. Worth remembering before keeping the
10633
+ * next dead field for the same reason. */
10620
10634
  /** Opt-in (default false) to using your content to improve Paigy and train models. */
10621
10635
  improveConsent: external_exports.boolean(),
10622
10636
  missedCall: MissedCallSchema.default("backoff_standard"),
@@ -10750,7 +10764,8 @@ var ConnectionSummarySchema = external_exports.object({
10750
10764
  });
10751
10765
  var LedgerItemSchema = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
10752
10766
  var AgentLedgerSchema = external_exports.object({
10753
- agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
10767
+ /** Null when the agent has not named itself yet — never a placeholder (owner, 2026-10-01). */
10768
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string().nullable(), revokedAt: external_exports.string().nullable() }),
10754
10769
  /** Its own questions you have not answered. */
10755
10770
  asks: external_exports.array(LedgerItemSchema),
10756
10771
  /** Its questions you answered that nobody acted on — still owed to somebody. */
@@ -10917,7 +10932,7 @@ var QueueItemSchema = external_exports.object({
10917
10932
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
10918
10933
  * answer to a question, a voice note filed as work. Null when the only hands on it have been its
10919
10934
  * agent's; optional, so a hand-built queue (fixtures, the demo) and a door older than
10920
- * 20260930222356 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
10935
+ * 20261001030911 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
10921
10936
  * Work tab orders as it did before the migration rather than throwing on the missing key.
10922
10937
  *
10923
10938
  * It is a FACT, not a reconstruction: `latest` holds one Entry, so an agent's progress note a
@@ -11234,7 +11249,9 @@ var CallRecapSchema = external_exports.object({
11234
11249
  * summarized like a pre-made option is"), so the row scans like a chosen option and their
11235
11250
  * own words stay under it. Absent on every line stored before the read wrote them, and on
11236
11251
  * anything that is not an answer. */
11237
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional() }))
11252
+ /** `about` is the request the line answered (its question), null for words that answered none —
11253
+ * the key the screen groups on, so one question is one row however many times it was answered. */
11254
+ lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
11238
11255
  })),
11239
11256
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
11240
11257
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
@@ -11433,6 +11450,22 @@ async function claimSessions(opts = {}) {
11433
11450
  if (!res.ok) throw new Error(`claim_sessions failed: ${res.status}`);
11434
11451
  return (await res.json()).sessions;
11435
11452
  }
11453
+ async function claimSessionEnds(opts = {}) {
11454
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/ends/claim`, {
11455
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
11456
+ // the HOST's identity
11457
+ }));
11458
+ if (!res.ok) throw new Error(`claim_session_ends failed: ${res.status}`);
11459
+ return (await res.json()).ends ?? [];
11460
+ }
11461
+ async function finishSessionEnd(endId, result, opts = {}) {
11462
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/ends/${encodeURIComponent(endId)}`, {
11463
+ method: "POST",
11464
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
11465
+ body: JSON.stringify({ result })
11466
+ }));
11467
+ if (!res.ok) await fail("finish_session_end", res);
11468
+ }
11436
11469
  async function recordDecision(decision, opts = {}) {
11437
11470
  const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/host/decisions`, {
11438
11471
  method: "POST",
@@ -11556,7 +11589,9 @@ async function contact(input, opts = {}) {
11556
11589
  // this cannot see.
11557
11590
  repo: repoFromRemote(a.repo) ?? a.repo ?? currentRepo() ?? void 0,
11558
11591
  ask: a.ask,
11559
- options: a.options ?? []
11592
+ options: a.options ?? [],
11593
+ ...a.select ? { select: a.select } : {},
11594
+ ...a.answers ? { answers: a.answers } : {}
11560
11595
  }));
11561
11596
  const res = await ensureAuthed(await send22(`${BACKEND_URL}/api/goals/contact`, {
11562
11597
  method: "POST",
@@ -12554,6 +12589,59 @@ function holds(pid, procStart, deps) {
12554
12589
  }
12555
12590
  return false;
12556
12591
  }
12592
+ function proven(pid, procStart, deps) {
12593
+ if (!deps.alive(pid) || typeof procStart !== "string" || !procStart.trim()) return false;
12594
+ const want = squash(procStart);
12595
+ return ["UTC", "local"].some((zone) => {
12596
+ const got = deps.started(pid, zone);
12597
+ return got !== null && squash(got) === want;
12598
+ });
12599
+ }
12600
+ function localHolderPids(deps = {}) {
12601
+ const dir = deps.dir ?? join6(process.env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir6(), ".claude"), "sessions");
12602
+ const list = deps.list ?? ((d) => readdirSync2(d));
12603
+ const read = deps.read ?? ((f) => readFileSync4(f, "utf8"));
12604
+ const check = { alive: deps.alive ?? isAlive, started: deps.started ?? psStart };
12605
+ const held = /* @__PURE__ */ new Map();
12606
+ let files;
12607
+ try {
12608
+ files = list(dir);
12609
+ } catch {
12610
+ return held;
12611
+ }
12612
+ for (const file of files) {
12613
+ if (!/^\d+\.json$/.test(file)) continue;
12614
+ let row;
12615
+ try {
12616
+ row = JSON.parse(read(join6(dir, file)));
12617
+ } catch {
12618
+ continue;
12619
+ }
12620
+ const pid = typeof row.pid === "number" ? row.pid : Number(file.slice(0, -5));
12621
+ if (typeof row.sessionId !== "string" || !row.sessionId.trim() || !Number.isInteger(pid) || pid <= 0) continue;
12622
+ if (!holds(pid, row.procStart, check)) continue;
12623
+ const slot = sessionSlot(row.sessionId);
12624
+ held.set(slot, [...held.get(slot) ?? [], { pid, proven: proven(pid, row.procStart, check) }]);
12625
+ }
12626
+ return held;
12627
+ }
12628
+ function psParent(pid) {
12629
+ try {
12630
+ const out = execFileSync3("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", timeout: 2e3 }).trim();
12631
+ const n = Number(out);
12632
+ return Number.isInteger(n) && n > 0 ? n : null;
12633
+ } catch {
12634
+ return null;
12635
+ }
12636
+ }
12637
+ function descendsFrom(pid, ancestor, parent = psParent) {
12638
+ let at = pid;
12639
+ for (let i = 0; i < 32 && at !== null && at > 1; i += 1) {
12640
+ if (at === ancestor) return true;
12641
+ at = parent(at);
12642
+ }
12643
+ return false;
12644
+ }
12557
12645
  function localHolders(deps = {}) {
12558
12646
  const dir = deps.dir ?? join6(process.env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir6(), ".claude"), "sessions");
12559
12647
  const list = deps.list ?? ((d) => readdirSync2(d));
@@ -12583,6 +12671,65 @@ function localHolders(deps = {}) {
12583
12671
  return held;
12584
12672
  }
12585
12673
 
12674
+ // src/end.ts
12675
+ var GRACE_MS = 5e3;
12676
+ async function endTerminal(pid, slot, deps) {
12677
+ try {
12678
+ deps.kill(pid, "SIGTERM");
12679
+ } catch (e) {
12680
+ return { ok: false, kind: "terminal", pid, why: `SIGTERM refused: ${e.message}`.slice(0, 200) };
12681
+ }
12682
+ const grace = deps.graceMs ?? GRACE_MS;
12683
+ for (let waited = 0; waited < grace; waited += 250) {
12684
+ if (!deps.alive(pid)) {
12685
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, pid, kind: "terminal", signal: "SIGTERM" })}`);
12686
+ return { ok: true, kind: "terminal", pid, signal: "SIGTERM" };
12687
+ }
12688
+ await deps.sleep(250);
12689
+ }
12690
+ try {
12691
+ deps.kill(pid, "SIGKILL");
12692
+ } catch (e) {
12693
+ return { ok: false, kind: "terminal", pid, why: `SIGKILL refused: ${e.message}`.slice(0, 200) };
12694
+ }
12695
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, pid, kind: "terminal", signal: "SIGKILL" })}`);
12696
+ return { ok: true, kind: "terminal", pid, signal: "SIGKILL" };
12697
+ }
12698
+ async function endSession(end, deps) {
12699
+ const slots = deps.slotsFor(end.agent);
12700
+ if (!slots.length) return { ok: false, why: "not_on_this_host" };
12701
+ let ended2 = null;
12702
+ if (end.which === "harness" || end.which === "any") {
12703
+ for (const slot of slots) {
12704
+ const run = deps.ownRun(slot);
12705
+ if (!run) continue;
12706
+ run.stop();
12707
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, kind: "harness", signal: "stop" })}`);
12708
+ ended2 = { ok: true, kind: "harness", signal: "stop" };
12709
+ }
12710
+ if (end.which === "harness" && !ended2) return { ok: false, kind: "harness", why: "no_harness_copy_here" };
12711
+ }
12712
+ if (end.which === "terminal" || end.which === "any") {
12713
+ let terminal = null;
12714
+ let unproven = null;
12715
+ for (const slot of slots) {
12716
+ for (const { pid, proven: proven2 } of deps.pidsFor(slot).filter((p) => !deps.isOwn(p.pid))) {
12717
+ if (!proven2) {
12718
+ deps.log(`session_end_refused ${JSON.stringify({ sessionId: slot, pid, why: "no_proc_start" })}`);
12719
+ unproven = { ok: false, kind: "terminal", pid, why: "no_proc_start" };
12720
+ continue;
12721
+ }
12722
+ terminal = await endTerminal(pid, slot, deps);
12723
+ if (!terminal.ok) return terminal;
12724
+ }
12725
+ }
12726
+ if (terminal) return terminal;
12727
+ if (unproven) return unproven;
12728
+ if (end.which === "terminal") return { ok: false, kind: "terminal", why: "no_terminal_copy_here" };
12729
+ }
12730
+ return ended2 ?? { ok: false, why: "nothing_running_here" };
12731
+ }
12732
+
12586
12733
  // src/owner.ts
12587
12734
  import { closeSync as closeSync2, openSync as openSync2, readSync, readdirSync as readdirSync3 } from "fs";
12588
12735
  import { homedir as homedir7 } from "os";
@@ -13979,7 +14126,20 @@ var AskInputSchema2 = external_exports.object({
13979
14126
  ask: external_exports.string().trim().min(1).max(1e4).describe(
13980
14127
  "ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 each question a Question on the one Goal the ask lands on, never a Goal of its own \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; ANY contact for a person already on a call JOINS that call, whatever channel you asked for, so several arrive as one call."
13981
14128
  ),
13982
- options: external_exports.array(OptionInputSchema2).min(1).max(6).optional()
14129
+ options: external_exports.array(OptionInputSchema2).min(1).max(6).optional(),
14130
+ // A CHECKLIST WHEN THE CHOICES ARE INDEPENDENT (owner, 2026-10-02 01:44 UTC: "bring back the
14131
+ // checklist mode of answering"). The answer shape was taken off this surface with the wire form
14132
+ // (#575) on the promise that Paigy would derive it, and the Goal-scoped contact that replaced it
14133
+ // only ever sends pick-one or words (`goal/intake.ts` `answerOf`): three independent fixes went out
14134
+ // as "All three / 1 and 3 only / Just log it", which the person could not read without the text and
14135
+ // could not answer as the ticks he wanted. "many" is the one shape an agent knows and the read does
14136
+ // not: whether its own options exclude each other.
14137
+ select: external_exports.enum(["one", "many"]).optional().describe(
14138
+ 'How the options are answered: "one" (default) to pick one, "many" to tick any number of them. Use "many" when the options are independent things they may want several of \u2014 never combinations of them like "all three" or "1 and 3". Ignored without options.'
14139
+ ),
14140
+ answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
14141
+ "Your reply answers a question the person asked you: the id the conversation shows for it (8 characters or whole). Their question closes with this reply as its answer, and any decision of yours it was holding back goes back to them. Needs parentId: the Goal the question is on. The reply is sent to them as it is; to also ask something new, send that as its own ask."
14142
+ )
13983
14143
  }).strict();
13984
14144
  var StartContactSchema2 = external_exports.object({
13985
14145
  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."),
@@ -13988,7 +14148,7 @@ var StartContactSchema2 = external_exports.object({
13988
14148
  }).strict();
13989
14149
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
13990
14150
  var CONTACT_SCHEMA2 = { type: "object", ...mcpInputSchema2(ContactSchema2) };
13991
- 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, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
14151
+ var CONTACT_DESCRIPTION2 = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'select', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read, every one on the Goal the ask lands on; separate pieces of work are separate asks. An ask with no options that is not blocking is a report, and a report is an UPDATE, never a claim on them: it reaches them and is listed apart from what waits on them, so no answer is owed and none should be awaited. On a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so). Send options (or waiting:'hard') when you actually need an answer. Each option's label must stand on its own \u2014 the person may see only the labels \u2014 so never a label that points into your text ('All three', 'Option 2', '1 and 3 only'); when the options are independent and they may want several, pass select:'many' and they get a checklist. If the person is already on a call, your contact joins that call automatically \u2014 whatever channel you asked for, with no ring \u2014 and the Delivery it returns IS that call: reread it with contact({deliveryId}) to see everything answered on it so far, and contact again while it is live to add information or a further question to the same call.";
13992
14152
  var CreateGoalSchema2 = external_exports.object({
13993
14153
  outcome: external_exports.string().trim().min(1).max(1e4),
13994
14154
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -14047,8 +14207,8 @@ var GetGoalSchema2 = external_exports.object({
14047
14207
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
14048
14208
  diagnose: external_exports.boolean().optional()
14049
14209
  }).strict();
14050
- 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), `next`, the one step to take, and `more`, where to read further. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
14051
- 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. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
14210
+ 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), `next`, the one step to take, and `more`, where to read further. A question in that conversation reads `open` (nothing yet), `answered` (a choice was made, and `answer` carries it), `replied` (they said something and the read settled the question on their words \u2014 NO option of yours was chosen, and the words are the reply line beside it, so read that before you act), or `closed`. The conversation carries everything the person said, every open question and your newest entry; your older entries are left out and counted \u2014 history: true reads every entry in full. Another account's Goals, and another agent's Goal that is not below one of yours, are not disclosed.\n\ndiagnose: true is for when a read has CONFUSED you \u2014 not for the working loop. It adds `diagnosis`, which asks every reader that already answers a question about your work and NAMES the reader behind every value, so you never guess which of two places to look: what state your work is really in (the stored `goals.state` beside the derived `goal_execution_state`, which can differ); whether anything is armed to ring and when (`ladder_candidates`, with the ladder's own last decision); whether a wake fired for you and what it did (durable `wake.*` events, beside when your process was last heard from); and what has reached you (`list_waiting` \u2014 review flags, unstarted work, open questions, the person's newest words). Read `diagnosis.disagreements` FIRST: each one is two readers giving different values for the same fact, with both values and both sources, and it never chooses between them \u2014 that is yours to do, from the Goal's own history. `diagnosis.looked` names every reader asked, including any that could not answer, so an empty answer is never confused with a broken one.";
14211
+ 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. A CONTACT ON THIS GOAL MOVES ITS REVISION: a question filed on a Goal is a change to it, so an update prepared before a contact and sent after it is refused as stale (409 goal_revision_conflict) \u2014 re-read the Goal, then write. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. withdraw: [questionId] takes back a question YOU asked on this Goal that is still open \u2014 because you acted on it yourself, it no longer matters, or you asked it wrongly (e.g. waiting: hard when nothing was blocked): it is cancelled, not answered, its card closes and it stops ringing; the id is the one the conversation shows. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. state: done while children are still open records your part as done: the Goal waits and closes by itself when its last open child closes. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
14052
14212
  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.";
14053
14213
  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.";
14054
14214
  var CheckRepliesSchema2 = external_exports.object({}).strict();
@@ -14630,12 +14790,13 @@ var UserSettingsSchema2 = external_exports.object({
14630
14790
  * they are an enum on a live column across every account, and the rename is a rename of
14631
14791
  * what people read (owner, 2026-09-30). */
14632
14792
  sessionMode: external_exports.enum(["default", "all_calls", "silent"]),
14633
- /** DEAD, AND ACCEPTED ANYWAY (#2813). DateNight is inbox-only with no way back, so nothing
14634
- * reads this. It stays OPTIONAL rather than deleted because a phone on the old bundle
14635
- * PATCHes the whole settings object and would be refused for sending a key we stopped
14636
- * wanting — the same trap `voiceMode` below already documents. #2813 drops the field and
14637
- * its column once no installed build still sends it. */
14638
- silentPush: external_exports.boolean().optional(),
14793
+ /** `silentPush` lived here until #2813 and is now GONE, field and column both. It was kept as an
14794
+ * optional long after DateNight stopped reading it, on the theory that a phone on an older
14795
+ * bundle PATCHing the whole settings object would be REFUSED for sending a key we had stopped
14796
+ * wanting. That theory was wrong about this schema: these are plain `z.object`s with no
14797
+ * `.strict()` anywhere in this file, and zod STRIPS unknown keys rather than rejecting them, so
14798
+ * an old bundle's `silentPush` is accepted and ignored. Worth remembering before keeping the
14799
+ * next dead field for the same reason. */
14639
14800
  /** Opt-in (default false) to using your content to improve Paigy and train models. */
14640
14801
  improveConsent: external_exports.boolean(),
14641
14802
  missedCall: MissedCallSchema2.default("backoff_standard"),
@@ -14769,7 +14930,8 @@ var ConnectionSummarySchema2 = external_exports.object({
14769
14930
  });
14770
14931
  var LedgerItemSchema2 = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
14771
14932
  var AgentLedgerSchema2 = external_exports.object({
14772
- agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
14933
+ /** Null when the agent has not named itself yet — never a placeholder (owner, 2026-10-01). */
14934
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string().nullable(), revokedAt: external_exports.string().nullable() }),
14773
14935
  /** Its own questions you have not answered. */
14774
14936
  asks: external_exports.array(LedgerItemSchema2),
14775
14937
  /** Its questions you answered that nobody acted on — still owed to somebody. */
@@ -14936,7 +15098,7 @@ var QueueItemSchema2 = external_exports.object({
14936
15098
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
14937
15099
  * answer to a question, a voice note filed as work. Null when the only hands on it have been its
14938
15100
  * agent's; optional, so a hand-built queue (fixtures, the demo) and a door older than
14939
- * 20260930222356 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
15101
+ * 20261001030911 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
14940
15102
  * Work tab orders as it did before the migration rather than throwing on the missing key.
14941
15103
  *
14942
15104
  * It is a FACT, not a reconstruction: `latest` holds one Entry, so an agent's progress note a
@@ -15251,7 +15413,9 @@ var CallRecapSchema2 = external_exports.object({
15251
15413
  * summarized like a pre-made option is"), so the row scans like a chosen option and their
15252
15414
  * own words stay under it. Absent on every line stored before the read wrote them, and on
15253
15415
  * anything that is not an answer. */
15254
- lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional() }))
15416
+ /** `about` is the request the line answered (its question), null for words that answered none —
15417
+ * the key the screen groups on, so one question is one row however many times it was answered. */
15418
+ lines: external_exports.array(external_exports.object({ entryId: external_exports.string().uuid(), words: external_exports.string(), headline: external_exports.string().optional(), about: external_exports.string().nullable().optional() }))
15255
15419
  })),
15256
15420
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
15257
15421
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
@@ -16687,6 +16851,31 @@ function startHost(opts) {
16687
16851
  void refreshIdentities();
16688
16852
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
16689
16853
  }
16854
+ const ends = await claimSessionEnds(asHost).catch(() => []);
16855
+ for (const end of ends) {
16856
+ const result = await endSession(end, {
16857
+ slotsFor: (agent2) => listSlots().filter((slot) => slot !== "Desktop" && slotIdentity(slot).tokenId === agent2),
16858
+ pidsFor: (slot) => localHolderPids().get(slot) ?? [],
16859
+ ownRun: (slot) => runs.get(runKey(slot))?.run ?? null,
16860
+ isOwn: (pid) => descendsFrom(pid, process.pid),
16861
+ kill: (pid, signal) => {
16862
+ process.kill(pid, signal);
16863
+ },
16864
+ alive: (pid) => {
16865
+ try {
16866
+ process.kill(pid, 0);
16867
+ return true;
16868
+ } catch (e) {
16869
+ return e.code === "EPERM";
16870
+ }
16871
+ },
16872
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
16873
+ log: opts.log
16874
+ }).catch((e) => ({ ok: false, why: `end_threw: ${e.message}`.slice(0, 200) }));
16875
+ opts.log(result.ok ? `\u25A0 ended ${end.which} copy of ${end.agent.slice(0, 8)}` : `\u25CC end of ${end.agent.slice(0, 8)} not done here: ${result.why}`);
16876
+ await finishSessionEnd(end.endId, result, asHost).catch(() => {
16877
+ });
16878
+ }
16690
16879
  }
16691
16880
  async function presenceMap() {
16692
16881
  const presence = /* @__PURE__ */ new Map();
@@ -16711,6 +16900,14 @@ function startHost(opts) {
16711
16900
  const key = runKey(slot);
16712
16901
  if (slot === "Desktop" || isRevoked(slot)) return;
16713
16902
  const live = runs.get(key);
16903
+ if (live) {
16904
+ const theirs = (localHolderPids().get(slot) ?? []).map((p) => p.pid).filter((pid) => !descendsFrom(pid, process.pid));
16905
+ if (theirs.length) {
16906
+ opts.log(`session_stood_back ${JSON.stringify({ sessionId: slot, livePid: theirs[0] })} \u2014 a terminal runs this conversation; stopping this host's copy`);
16907
+ live.run.stop();
16908
+ return;
16909
+ }
16910
+ }
16714
16911
  const tokenId = slotIdentity(slot).tokenId;
16715
16912
  const seenByServer = tokenId ? presence.get(tokenId) : void 0;
16716
16913
  const holder = holders.get(slot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/harness",
3
- "version": "0.3.30",
3
+ "version": "0.3.31",
4
4
  "description": "Run Claude Code / Codex on this machine, bridged to Paigy — launchable from your phone.",
5
5
  "license": "MIT",
6
6
  "type": "module",