@paigy/harness 0.3.31 → 0.3.32

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 (4) hide show
  1. package/README.md +10 -4
  2. package/dist/cli.js +181 -110
  3. package/dist/main.js +113 -63
  4. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -4578,9 +4578,9 @@ var require_lib = __commonJS({
4578
4578
  // src/main.ts
4579
4579
  import { app, BrowserWindow, dialog, ipcMain } from "electron";
4580
4580
  import { spawn as spawn2 } from "child_process";
4581
- import { readFileSync as readFileSync6 } from "fs";
4581
+ import { readFileSync as readFileSync7 } from "fs";
4582
4582
  import { fileURLToPath } from "url";
4583
- import { dirname as dirname4, join as join11 } from "path";
4583
+ import { dirname as dirname4, join as join12 } from "path";
4584
4584
 
4585
4585
  // ../../packages/sdk/dist/index.js
4586
4586
  import { createRequire as __sdkCreateRequire } from "module";
@@ -8641,6 +8641,7 @@ import { join as join2 } from "path";
8641
8641
  import { execFileSync as execFileSync2 } from "child_process";
8642
8642
  var import_undici = __toESM(require_undici(), 1);
8643
8643
  var require2 = __sdkCreateRequire(import.meta.url);
8644
+ var CODEX_ENV = ["CODEX_THREAD_ID", "CODEX_SESSION_ID", "PAIGY_TOKEN", "PAIGY_SESSION_ID", "PAIGY_HARNESS", "PAIGY_INSTANCE_ID"];
8644
8645
  var BACKEND_URL = process.env.PAIGY_BACKEND_URL ?? "https://paigy.ai";
8645
8646
  var NETWORK_MSG = `Can't reach ${BACKEND_URL} \u2014 the connection was blocked or dropped before an HTTP response. If this agent runs in a sandboxed environment with a network allowlist (e.g. Claude Code on the web, CI), ask the user to add the Paigy domain (paigy.ai) to the environment's allowed domains, then retry.`;
8646
8647
  var PROXY_ENV = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
@@ -8695,14 +8696,14 @@ function sessionId() {
8695
8696
  async function reach(url, init) {
8696
8697
  return send(url, init, sessionId());
8697
8698
  }
8698
- function reachAs(session) {
8699
- return (url, init) => send(url, init, session);
8699
+ function reachAs(session, instance) {
8700
+ return (url, init) => send(url, init, session, instance);
8700
8701
  }
8701
- async function send(url, init, session) {
8702
+ async function send(url, init, session, instance = envSession("PAIGY_INSTANCE_ID") ?? INSTANCE_ID) {
8702
8703
  try {
8703
8704
  const headers = {
8704
8705
  ...init?.headers,
8705
- "x-paigy-instance": INSTANCE_ID,
8706
+ "x-paigy-instance": instance,
8706
8707
  "x-paigy-session": session
8707
8708
  };
8708
8709
  return await fetch(url, { ...init, headers, dispatcher: await proxy() });
@@ -9976,15 +9977,13 @@ var AskInputSchema = external_exports.object({
9976
9977
  "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
9978
  ),
9978
9979
  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.
9980
+ // YOUR VIEW OF ONE OR MANY, A HINT TO PAIGY'S READ (owner, 2026-10-02: "one and many makes sense...
9981
+ // make sure the brain that formats is aware of how it should work"). Three independent fixes went
9982
+ // out as "All three / 1 and 3 only / Just log it" because every card was pick-one. #2943 made this
9983
+ // field override the read; no agent sent it, so nothing changed. The read now decides each card's
9984
+ // shape (`goal/intake.ts` `answerOf`), and this reaches it as the agent's view, weighed, not obeyed.
9986
9985
  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.'
9986
+ `Your view of how the options are answered: "one" to pick one, "many" to tick any number of them. A hint: Paigy's read decides each card's shape, a checklist when the options can all be wanted at once, a pick when choosing one rules out the others. The person can always answer in their own words as well. Ignored without options.`
9988
9987
  ),
9989
9988
  answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
9990
9989
  "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."
@@ -9997,7 +9996,7 @@ var StartContactSchema = external_exports.object({
9997
9996
  }).strict();
9998
9997
  var ContactSchema = external_exports.union([StartContactSchema, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
9999
9998
  var CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
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.";
9999
+ 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'). Whether a card is a checklist or a pick is decided by Paigy's read: independent options they may want several of become a checklist, alternatives a pick. select:'many' or select:'one' tells it your view, a hint it weighs. The person can always answer in their own words, so never add an 'Other' option. 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.";
10001
10000
  var CreateGoalSchema = external_exports.object({
10002
10001
  outcome: external_exports.string().trim().min(1).max(1e4),
10003
10002
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -10020,7 +10019,7 @@ var CreateGoalSchema = external_exports.object({
10020
10019
  var CreateGoalToolSchema = CreateGoalSchema.extend({
10021
10020
  idempotencyKey: CreateGoalSchema.shape.idempotencyKey.optional().describe("Optional. One is minted per call; pass your own only so a retry lands on the same Goal.")
10022
10021
  }).strict();
10023
- var CREATE_GOAL_DESCRIPTION = 'Create a durable Goal for an outcome. Without parentGoalId it is placed against your open Goals: if one already IS this work, that Goal comes back (existing: true) and nothing new is created \u2014 continue it; if the work belongs under one, it is created there (parentGoalId in the receipt); otherwise it is a root. Pass parentGoalId yourself to put it under a specific Goal. Pass repo to anchor the work to a specific repository ("owner/repo" or repo name); delegated children inherit it. Pass title to name it in one to five words, as a person would refer to it out loud ("the night rings") \u2014 it heads every list and is spoken on a call; without one the brain writes it. Admission only: the owner must claim it before doing work, then update it as it advances. Returns an admission receipt with goalId, current state, revision, ownerParticipant, and the next step; no Goal content or execution lease.';
10022
+ var CREATE_GOAL_DESCRIPTION = 'Create a durable Goal for an outcome. Without parentGoalId it is placed against your open Goals: if one already IS this work, that Goal comes back (existing: true) and nothing new is created \u2014 continue it; if the work belongs under one, it is created there (parentGoalId in the receipt); otherwise it is a root. Pass parentGoalId yourself to put it under a specific Goal. Pass repo to anchor the work to a specific repository ("owner/repo" or repo name); delegated children inherit it. Pass title to name it in one to five words, as a person would refer to it out loud ("the night rings") \u2014 it heads every list and is spoken on a call; without one the brain writes it. Admission only: claim it before doing work, then update it as it advances. Returns an admission receipt with goalId, current state, revision, ownerParticipant, and the next step; no Goal content or execution lease.';
10024
10023
  var UpdateGoalSchema = external_exports.object({
10025
10024
  revision: external_exports.number().int().positive(),
10026
10025
  changes: external_exports.object({
@@ -10056,12 +10055,13 @@ var GetGoalSchema = external_exports.object({
10056
10055
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
10057
10056
  diagnose: external_exports.boolean().optional()
10058
10057
  }).strict();
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.`;
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.";
10058
+ var GET_GOAL_DESCRIPTION = "Read one Goal without claiming it, including others (the ten most recent other contributors, with names, latest entry headlines and times): 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. Every Goal of your person is readable, whichever of their agents owns it; another account's Goals 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.";
10059
+ var UPDATE_GOAL_DESCRIPTION = `Update a Goal you are on at an exact revision. You are on a Goal you own, or one you have written on (an update with progress, or a contact on it); any of your person's agents may write on any of their Goals, and one that never touched a Goal is refused (409 goal_not_joined). 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.`;
10060
+ var CLAIM_GOAL_DESCRIPTION = "Claim a pending answer to your question or the oldest runnable or review-pending Goal you own. Pass goalId to join any Goal of your person; its assignment stays unchanged. Read others before overlapping another agent\u2019s work. Joining lets you contribute and change its state; use update_goal with ownerParticipant when the assignment itself should change. Returns the Goal as get_goal reads it, and marks you as on it, which never shuts another agent out: other agents of your person may write on it and change it too, and two changes at once are told apart by revision (409 goal_revision_conflict).";
10062
10061
  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.";
10063
10062
  var CheckRepliesSchema = external_exports.object({}).strict();
10064
10063
  var AGENT_TOOLS = [
10064
+ { name: "who_is_working", description: "Read your person's agents that contributed in the last 24 hours, newest first: at most 30 agents and their five most recently touched Goals, with their latest entry headline and time. Use get_goal to read a Goal and its other contributors before starting overlapping work. Read-only; no arguments.", inputSchema: mcpInputSchema(CheckRepliesSchema) },
10065
10065
  { name: "contact", description: CONTACT_DESCRIPTION, inputSchema: CONTACT_SCHEMA },
10066
10066
  { name: "check_replies", description: CHECK_REPLIES_DESCRIPTION, inputSchema: mcpInputSchema(CheckRepliesSchema) },
10067
10067
  { name: "create_goal", description: CREATE_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(CreateGoalToolSchema) },
@@ -10071,7 +10071,7 @@ var AGENT_TOOLS = [
10071
10071
  ];
10072
10072
  var AGENT_TOOL_NAMES = AGENT_TOOLS.map((t) => t.name);
10073
10073
  var LIVE_MS = 3 * 6e4;
10074
- var WORKING_MS = 30 * 6e4;
10074
+ var WORKING_MS = 60 * 6e4;
10075
10075
  var ContextSchema = external_exports.object({
10076
10076
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
10077
10077
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -10423,7 +10423,13 @@ var AgendaTurnSchema = external_exports.object({
10423
10423
  /** Whether the walk WAITS for an answer before moving on. Absent = derived as today
10424
10424
  * (a question blocks, context flows). blocking:false on a question = ask and move
10425
10425
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
10426
- blocking: external_exports.boolean().optional()
10426
+ blocking: external_exports.boolean().optional(),
10427
+ /** SPOKEN ONLY IF THEY SAY NOTHING (owner, 2026-10-01, call 812de935: "you're gonna re-ask, but it
10428
+ * shouldn't be the same words … more like, hey, are you still there, or are you able to answer, or
10429
+ * would you need more information"). The walk holds this turn out of its queue; at the queue's end it
10430
+ * listens for the last word, and only if that listen is silent is this turn said and asked. If they
10431
+ * speak, it is dropped and their words are taken like any reply. */
10432
+ ifSilent: external_exports.boolean().optional()
10427
10433
  });
10428
10434
  var CLAIM_STALE_MS = 30 * 6e4;
10429
10435
  var InboxItemSchema = external_exports.object({
@@ -11914,9 +11920,9 @@ function detectAll(deps = {}) {
11914
11920
  }
11915
11921
 
11916
11922
  // src/host.ts
11917
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
11923
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
11918
11924
  import { homedir as homedir10 } from "os";
11919
- import { join as join10 } from "path";
11925
+ import { join as join11 } from "path";
11920
11926
 
11921
11927
  // src/triage/evidence.ts
11922
11928
  var OPEN_STATUSES = ["open", "assigned", "in_progress"];
@@ -14127,15 +14133,13 @@ var AskInputSchema2 = external_exports.object({
14127
14133
  "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."
14128
14134
  ),
14129
14135
  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.
14136
+ // YOUR VIEW OF ONE OR MANY, A HINT TO PAIGY'S READ (owner, 2026-10-02: "one and many makes sense...
14137
+ // make sure the brain that formats is aware of how it should work"). Three independent fixes went
14138
+ // out as "All three / 1 and 3 only / Just log it" because every card was pick-one. #2943 made this
14139
+ // field override the read; no agent sent it, so nothing changed. The read now decides each card's
14140
+ // shape (`goal/intake.ts` `answerOf`), and this reaches it as the agent's view, weighed, not obeyed.
14137
14141
  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.'
14142
+ `Your view of how the options are answered: "one" to pick one, "many" to tick any number of them. A hint: Paigy's read decides each card's shape, a checklist when the options can all be wanted at once, a pick when choosing one rules out the others. The person can always answer in their own words as well. Ignored without options.`
14139
14143
  ),
14140
14144
  answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
14141
14145
  "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."
@@ -14148,7 +14152,7 @@ var StartContactSchema2 = external_exports.object({
14148
14152
  }).strict();
14149
14153
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
14150
14154
  var CONTACT_SCHEMA2 = { type: "object", ...mcpInputSchema2(ContactSchema2) };
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.";
14155
+ 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'). Whether a card is a checklist or a pick is decided by Paigy's read: independent options they may want several of become a checklist, alternatives a pick. select:'many' or select:'one' tells it your view, a hint it weighs. The person can always answer in their own words, so never add an 'Other' option. 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.";
14152
14156
  var CreateGoalSchema2 = external_exports.object({
14153
14157
  outcome: external_exports.string().trim().min(1).max(1e4),
14154
14158
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -14171,7 +14175,7 @@ var CreateGoalSchema2 = external_exports.object({
14171
14175
  var CreateGoalToolSchema2 = CreateGoalSchema2.extend({
14172
14176
  idempotencyKey: CreateGoalSchema2.shape.idempotencyKey.optional().describe("Optional. One is minted per call; pass your own only so a retry lands on the same Goal.")
14173
14177
  }).strict();
14174
- var CREATE_GOAL_DESCRIPTION2 = 'Create a durable Goal for an outcome. Without parentGoalId it is placed against your open Goals: if one already IS this work, that Goal comes back (existing: true) and nothing new is created \u2014 continue it; if the work belongs under one, it is created there (parentGoalId in the receipt); otherwise it is a root. Pass parentGoalId yourself to put it under a specific Goal. Pass repo to anchor the work to a specific repository ("owner/repo" or repo name); delegated children inherit it. Pass title to name it in one to five words, as a person would refer to it out loud ("the night rings") \u2014 it heads every list and is spoken on a call; without one the brain writes it. Admission only: the owner must claim it before doing work, then update it as it advances. Returns an admission receipt with goalId, current state, revision, ownerParticipant, and the next step; no Goal content or execution lease.';
14178
+ var CREATE_GOAL_DESCRIPTION2 = 'Create a durable Goal for an outcome. Without parentGoalId it is placed against your open Goals: if one already IS this work, that Goal comes back (existing: true) and nothing new is created \u2014 continue it; if the work belongs under one, it is created there (parentGoalId in the receipt); otherwise it is a root. Pass parentGoalId yourself to put it under a specific Goal. Pass repo to anchor the work to a specific repository ("owner/repo" or repo name); delegated children inherit it. Pass title to name it in one to five words, as a person would refer to it out loud ("the night rings") \u2014 it heads every list and is spoken on a call; without one the brain writes it. Admission only: claim it before doing work, then update it as it advances. Returns an admission receipt with goalId, current state, revision, ownerParticipant, and the next step; no Goal content or execution lease.';
14175
14179
  var UpdateGoalSchema2 = external_exports.object({
14176
14180
  revision: external_exports.number().int().positive(),
14177
14181
  changes: external_exports.object({
@@ -14207,12 +14211,13 @@ var GetGoalSchema2 = external_exports.object({
14207
14211
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
14208
14212
  diagnose: external_exports.boolean().optional()
14209
14213
  }).strict();
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.`;
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.";
14214
+ var GET_GOAL_DESCRIPTION2 = "Read one Goal without claiming it, including others (the ten most recent other contributors, with names, latest entry headlines and times): 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. Every Goal of your person is readable, whichever of their agents owns it; another account's Goals 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.";
14215
+ var UPDATE_GOAL_DESCRIPTION2 = `Update a Goal you are on at an exact revision. You are on a Goal you own, or one you have written on (an update with progress, or a contact on it); any of your person's agents may write on any of their Goals, and one that never touched a Goal is refused (409 goal_not_joined). 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.`;
14216
+ var CLAIM_GOAL_DESCRIPTION2 = "Claim a pending answer to your question or the oldest runnable or review-pending Goal you own. Pass goalId to join any Goal of your person; its assignment stays unchanged. Read others before overlapping another agent\u2019s work. Joining lets you contribute and change its state; use update_goal with ownerParticipant when the assignment itself should change. Returns the Goal as get_goal reads it, and marks you as on it, which never shuts another agent out: other agents of your person may write on it and change it too, and two changes at once are told apart by revision (409 goal_revision_conflict).";
14213
14217
  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.";
14214
14218
  var CheckRepliesSchema2 = external_exports.object({}).strict();
14215
14219
  var AGENT_TOOLS2 = [
14220
+ { name: "who_is_working", description: "Read your person's agents that contributed in the last 24 hours, newest first: at most 30 agents and their five most recently touched Goals, with their latest entry headline and time. Use get_goal to read a Goal and its other contributors before starting overlapping work. Read-only; no arguments.", inputSchema: mcpInputSchema2(CheckRepliesSchema2) },
14216
14221
  { name: "contact", description: CONTACT_DESCRIPTION2, inputSchema: CONTACT_SCHEMA2 },
14217
14222
  { name: "check_replies", description: CHECK_REPLIES_DESCRIPTION2, inputSchema: mcpInputSchema2(CheckRepliesSchema2) },
14218
14223
  { name: "create_goal", description: CREATE_GOAL_DESCRIPTION2, inputSchema: mcpInputSchema2(CreateGoalToolSchema2) },
@@ -14237,7 +14242,7 @@ function entryWords(entry) {
14237
14242
  return entry.sources.map((source) => source.text).join("\n");
14238
14243
  }
14239
14244
  var LIVE_MS2 = 3 * 6e4;
14240
- var WORKING_MS2 = 30 * 6e4;
14245
+ var WORKING_MS2 = 60 * 6e4;
14241
14246
  var ContextSchema2 = external_exports.object({
14242
14247
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
14243
14248
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -14589,7 +14594,13 @@ var AgendaTurnSchema2 = external_exports.object({
14589
14594
  /** Whether the walk WAITS for an answer before moving on. Absent = derived as today
14590
14595
  * (a question blocks, context flows). blocking:false on a question = ask and move
14591
14596
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
14592
- blocking: external_exports.boolean().optional()
14597
+ blocking: external_exports.boolean().optional(),
14598
+ /** SPOKEN ONLY IF THEY SAY NOTHING (owner, 2026-10-01, call 812de935: "you're gonna re-ask, but it
14599
+ * shouldn't be the same words … more like, hey, are you still there, or are you able to answer, or
14600
+ * would you need more information"). The walk holds this turn out of its queue; at the queue's end it
14601
+ * listens for the last word, and only if that listen is silent is this turn said and asked. If they
14602
+ * speak, it is dropped and their words are taken like any reply. */
14603
+ ifSilent: external_exports.boolean().optional()
14593
14604
  });
14594
14605
  var CLAIM_STALE_MS2 = 30 * 6e4;
14595
14606
  var InboxItemSchema2 = external_exports.object({
@@ -15441,11 +15452,14 @@ function sameTail(a, b) {
15441
15452
  return a.length === b.length && a.every((l, i) => l === b[i]);
15442
15453
  }
15443
15454
 
15455
+ // src/run.ts
15456
+ import { randomUUID as randomUUID6 } from "crypto";
15457
+
15444
15458
  // src/harness/session.ts
15445
15459
  import { spawn } from "child_process";
15446
- import { existsSync as existsSync4 } from "fs";
15460
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
15447
15461
  import { homedir as homedir9 } from "os";
15448
- import { resolve as resolve2, delimiter as delimiter3 } from "path";
15462
+ import { resolve as resolve2, delimiter as delimiter3, join as join9 } from "path";
15449
15463
 
15450
15464
  // src/harness/acp.ts
15451
15465
  var none = { events: [], writes: [] };
@@ -15816,6 +15830,21 @@ function startSession(opts) {
15816
15830
  if (!existsSync4(cwd)) {
15817
15831
  queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
15818
15832
  }
15833
+ const codexConfig = opts.harness === "codex" ? JSON.parse(process.env.CODEX_CONFIG || "{}") : null;
15834
+ if (codexConfig) {
15835
+ const key = "mcp_servers.paigy.env_vars";
15836
+ const configPath = join9(process.env.CODEX_HOME || join9(homedir9(), ".codex"), "config.toml");
15837
+ const saved = existsSync4(configPath) ? readFileSync5(configPath, "utf8") : "";
15838
+ const registered = /\[mcp_servers\.paigy\][\s\S]*?(?=\n\[|$)/.exec(saved)?.[0];
15839
+ const configured = codexConfig["mcp_servers.paigy.command"] || codexConfig["mcp_servers.paigy.url"] || codexConfig.mcp_servers?.paigy?.command || codexConfig.mcp_servers?.paigy?.url;
15840
+ if (!configured && !registered) {
15841
+ codexConfig["mcp_servers.paigy.command"] = "npx";
15842
+ codexConfig["mcp_servers.paigy.args"] = ["-y", "@paigy/mcp@latest"];
15843
+ }
15844
+ const savedVars = registered?.match(/env_vars\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? "";
15845
+ const inherited = [...savedVars.matchAll(/["']([A-Za-z_][A-Za-z0-9_]*)["']/g)].map((match) => match[1]);
15846
+ codexConfig[key] = [.../* @__PURE__ */ new Set([...inherited, ...codexConfig[key] ?? codexConfig.mcp_servers?.paigy?.env_vars ?? [], ...CODEX_ENV])];
15847
+ }
15819
15848
  const child = (opts.spawnFn ?? spawn)(opts.bin ?? ADAPTER_BIN[opts.harness], [], {
15820
15849
  cwd,
15821
15850
  // The adapter shells out to its vendor CLI (`claude`, `codex`), and a GUI- or
@@ -15826,7 +15855,9 @@ function startSession(opts) {
15826
15855
  ...process.env,
15827
15856
  PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter3),
15828
15857
  ...opts.token ? { PAIGY_TOKEN: opts.token } : {},
15829
- ...opts.resume ?? opts.session ? { PAIGY_SESSION_ID: opts.resume ?? opts.session } : {},
15858
+ ...opts.instance ? { PAIGY_INSTANCE_ID: opts.instance } : {},
15859
+ ...codexConfig ? { CODEX_CONFIG: JSON.stringify(codexConfig) } : {},
15860
+ ...opts.session ?? opts.resume ? { PAIGY_SESSION_ID: opts.session ?? opts.resume } : {},
15830
15861
  // A PUMP DRIVES THIS PROCESS (2026-09-22, #2265): every run `runHarness` starts has the
15831
15862
  // bridge's pump typing answers in, so nothing inside it may start a second delivery path.
15832
15863
  // Exported on EVERY spawn — `PAIGY_SESSION_ID` above is the conversation to resume, and a
@@ -16085,9 +16116,13 @@ async function finish(result, state, rail, opts) {
16085
16116
  const progress = result?.trim().slice(0, 1e4) || void 0;
16086
16117
  let revision = goal.revision;
16087
16118
  try {
16088
- const held = await rail.claim(goal.id);
16119
+ const held = await rail.read(goal.id);
16089
16120
  revision = held.revision ?? revision;
16090
16121
  const owed = state.noted?.some((b) => b.goals.includes(goal.id));
16122
+ if (held.state === "done" || held.state === "cancelled") {
16123
+ if (!owed) state.goal = void 0;
16124
+ return;
16125
+ }
16091
16126
  if (!held.openDecisionNeedIds?.length && !owed) {
16092
16127
  await rail.update(goal.id, { revision, changes: { state: "done", ...progress ? { progress } : {} }, reason: "The agent finished its turn." });
16093
16128
  state.goal = void 0;
@@ -16176,11 +16211,16 @@ function beat() {
16176
16211
  }
16177
16212
  var BEAT_REPORT_MS = 10 * 6e4;
16178
16213
  var ended = (state) => state === "done" || state === "cancelled";
16179
- function unheard(brief, state, since = null, after = -1) {
16214
+ function unheard(brief, state, since = null, after = -1, reviewSince) {
16180
16215
  const seen = state.seen ??= /* @__PURE__ */ new Set();
16181
16216
  const words2 = [];
16217
+ const pendingAt = reviewSince ? Date.parse(reviewSince) : null;
16182
16218
  for (const [i, e] of (brief.entries ?? []).entries()) {
16183
16219
  if (seen.has(e.entryId) || !e.authorParticipant.startsWith("human:")) continue;
16220
+ if (pendingAt !== null && Date.parse(e.createdAt) < pendingAt) {
16221
+ seen.add(e.entryId);
16222
+ continue;
16223
+ }
16184
16224
  if (i <= after) {
16185
16225
  seen.add(e.entryId);
16186
16226
  continue;
@@ -16191,6 +16231,12 @@ function unheard(brief, state, since = null, after = -1) {
16191
16231
  const text = entryWords(e).trim();
16192
16232
  if (text) words2.push(text);
16193
16233
  }
16234
+ if (pendingAt !== null) for (const answer of brief.answers ?? []) {
16235
+ const key = `answer:${answer.decisionNeedId}`;
16236
+ if (seen.has(key) || Date.parse(answer.acceptedAt) < pendingAt) continue;
16237
+ seen.add(key);
16238
+ words2.push(`Answer to question ${answer.decisionNeedId}: ${JSON.stringify(answer.result)}`);
16239
+ }
16194
16240
  return words2;
16195
16241
  }
16196
16242
  function sinceTheWork(brief) {
@@ -16259,9 +16305,9 @@ async function gather(state, rail, held) {
16259
16305
  }
16260
16306
  }
16261
16307
  read.add(goalId);
16262
- const fresh = found.via === "waiting work" || found.via === "assigned" ? sinceTheWork(brief) : { after: -1 };
16308
+ const fresh = !brief.reviewSince && (found.via === "waiting work" || found.via === "assigned") ? sinceTheWork(brief) : { after: -1 };
16263
16309
  if (found.via === "assigned") announced.add(goalId);
16264
- heard.push({ goalId, ...found, brief, words: unheard(brief, state, since ?? null, fresh.after), ...fresh.note ? { note: fresh.note } : {} });
16310
+ heard.push({ goalId, ...found, brief, words: unheard(brief, state, brief.reviewSince ? null : since ?? null, fresh.after, brief.reviewSince), ...fresh.note ? { note: fresh.note } : {} });
16265
16311
  }
16266
16312
  for (const { goal, since } of moved) if (read.has(goal.goalId)) state.below.goals.set(goal.goalId, { since, at: goal.updatedAt ?? null });
16267
16313
  return { heard, listed };
@@ -16347,8 +16393,8 @@ async function acknowledge(goalId, brief, state, rail, log2) {
16347
16393
  if (acked.has(goalId) && acked.get(goalId) === brief.revision) return;
16348
16394
  acked.set(goalId, brief.revision);
16349
16395
  try {
16350
- const claimed = await rail.claim(goalId);
16351
- const done = await rail.update(goalId, { revision: claimed.revision ?? brief.revision ?? 1, changes: { reviewed: true }, reason: "Read by the harness session; the person's words on it are typed in." });
16396
+ if (brief.revision === void 0) throw new Error("review_revision_missing");
16397
+ const done = await rail.update(goalId, { revision: brief.revision, changes: { reviewed: true }, reason: "Read by the harness session; the person's words on it are typed in." });
16352
16398
  acked.set(goalId, done.revision);
16353
16399
  if (state.goal?.id === goalId && done.revision !== void 0) state.goal.revision = done.revision;
16354
16400
  } catch (error) {
@@ -16385,25 +16431,26 @@ async function look(session, state, rail, opts) {
16385
16431
  brief2 = await rail.claim();
16386
16432
  if (!brief2.goalId || brief2.revision === void 0) {
16387
16433
  state.goal = { id: admitted.goalId, revision: admitted.revision };
16388
- send2(session, state, [HARNESS_INSTRUCTION, prompt].join("\n\n"));
16434
+ send2(session, state, [HARNESS_INSTRUCTION, named(admitted.goalId, void 0, { outcome: prompt }), prompt].join("\n\n"));
16389
16435
  return;
16390
16436
  }
16391
16437
  } else {
16392
16438
  return;
16393
16439
  }
16394
16440
  }
16395
- unheard(brief2, state);
16396
- if (ended(brief2.state)) await rail.update(brief2.goalId, { revision: brief2.revision, changes: { reviewed: true }, reason: "Typed into the session." });
16397
- else {
16441
+ const pendingWords = brief2.reviewSince ? unheard(brief2, state, null, -1, brief2.reviewSince) : null;
16442
+ if (!pendingWords) unheard(brief2, state);
16443
+ if (!ended(brief2.state)) {
16398
16444
  state.goal = { id: brief2.goalId, revision: brief2.revision };
16399
16445
  state.below = baseline(brief2.goalId, brief2.subtree);
16400
16446
  }
16401
16447
  const outcome2 = brief2.outcome?.trim() ?? "";
16402
- const said = (brief2.entries ?? []).map((e) => entryWords(e).trim()).filter((w) => w && w.toLowerCase() !== outcome2.toLowerCase());
16448
+ const said = (pendingWords ?? (brief2.entries ?? []).map((e) => entryWords(e).trim())).filter((w) => w && w.toLowerCase() !== outcome2.toLowerCase());
16403
16449
  const left = brief2.earlier ? brief2.earlier.omitted + brief2.earlier.shortened : 0;
16404
16450
  const earlier = left ? `(${left} earlier entries on this Goal are left out or shortened; get_goal with history: true reads them in full.)` : "";
16405
16451
  const fullText = [outcome2, earlier, ...said].filter(Boolean).join("\n\n");
16406
- send2(session, state, [HARNESS_INSTRUCTION, fullText].filter(Boolean).join("\n\n"));
16452
+ send2(session, state, [HARNESS_INSTRUCTION, named(brief2.goalId, void 0, brief2), fullText].filter(Boolean).join("\n\n"));
16453
+ if (ended(brief2.state)) await acknowledge(brief2.goalId, brief2, state, rail, opts?.log);
16407
16454
  return;
16408
16455
  }
16409
16456
  const brief = await rail.read(state.goal.id);
@@ -16412,6 +16459,7 @@ async function look(session, state, rail, opts) {
16412
16459
  const resolve3 = state.asks?.get(answer.requestEntryId);
16413
16460
  if (!resolve3) continue;
16414
16461
  state.asks.delete(answer.requestEntryId);
16462
+ (state.seen ??= /* @__PURE__ */ new Set()).add(`answer:${answer.decisionNeedId}`);
16415
16463
  resolve3(answer.result);
16416
16464
  }
16417
16465
  if (ended(brief.state)) {
@@ -16489,8 +16537,9 @@ function runHarness(opts) {
16489
16537
  const state = { resting: true };
16490
16538
  const clock4 = beat();
16491
16539
  let session = null;
16492
- const speaksAs = opts.resume ?? opts.session;
16493
- const rail = railFor({ token: opts.token, ...speaksAs ? { reach: reachAs(speaksAs) } : {} });
16540
+ const speaksAs = opts.session ?? opts.resume ?? sessionId();
16541
+ const instance = randomUUID6();
16542
+ const rail = railFor({ token: opts.token, reach: reachAs(speaksAs, instance) });
16494
16543
  let inbox;
16495
16544
  if (opts.harness === "claude") {
16496
16545
  try {
@@ -16546,6 +16595,7 @@ ${event.message}`, state, rail, { log: opts.log });
16546
16595
  }
16547
16596
  }
16548
16597
  session = startSession({
16598
+ instance,
16549
16599
  harness: opts.harness,
16550
16600
  cwd: opts.cwd,
16551
16601
  mode: opts.mode,
@@ -16589,7 +16639,7 @@ ${event.message}`, state, rail, { log: opts.log });
16589
16639
  // src/update.ts
16590
16640
  import { execFile } from "child_process";
16591
16641
  import { createRequire } from "module";
16592
- import { dirname as dirname3, join as join9 } from "path";
16642
+ import { dirname as dirname3, join as join10 } from "path";
16593
16643
  import { promisify } from "util";
16594
16644
  var SERVICE_LABEL = "ai.paigy.harness";
16595
16645
  var VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -16616,7 +16666,7 @@ async function latestVersion() {
16616
16666
  }
16617
16667
  async function install(version) {
16618
16668
  const bin = dirname3(process.execPath);
16619
- await promisify(execFile)(join9(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
16669
+ await promisify(execFile)(join10(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
16620
16670
  env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, npm_config_update_notifier: "false" },
16621
16671
  timeout: 5 * 6e4
16622
16672
  });
@@ -16667,7 +16717,7 @@ function selfUpdater(deps) {
16667
16717
  }
16668
16718
 
16669
16719
  // src/host.ts
16670
- var HOST_FILE = join10(homedir10(), ".paigy", "host.json");
16720
+ var HOST_FILE = join11(homedir10(), ".paigy", "host.json");
16671
16721
  function wakeHarness(slot, saved, claudeTranscript) {
16672
16722
  if (slot === "Desktop") return void 0;
16673
16723
  if (saved !== null) return saved === "agy" || saved === "codex" || saved === "claude" ? saved : void 0;
@@ -16679,7 +16729,7 @@ function wakeHarness(slot, saved, claudeTranscript) {
16679
16729
  }
16680
16730
  function readHostState() {
16681
16731
  try {
16682
- const s = JSON.parse(readFileSync5(HOST_FILE, "utf8"));
16732
+ const s = JSON.parse(readFileSync6(HOST_FILE, "utf8"));
16683
16733
  if (Date.now() - s.at > 2e4) return null;
16684
16734
  try {
16685
16735
  process.kill(s.pid, 0);
@@ -17153,7 +17203,7 @@ var win = null;
17153
17203
  function log(message) {
17154
17204
  win?.webContents.send("paigy:log", message);
17155
17205
  }
17156
- var ICON = join11(here, "..", "assets", "icon.png");
17206
+ var ICON = join12(here, "..", "assets", "icon.png");
17157
17207
  function createWindow() {
17158
17208
  win = new BrowserWindow({
17159
17209
  // Sized for the working log, which is now the window's centerpiece; the layout is a
@@ -17165,9 +17215,9 @@ function createWindow() {
17165
17215
  minHeight: 520,
17166
17216
  icon: ICON,
17167
17217
  // Windows/Linux window icon; macOS uses the dock icon below
17168
- webPreferences: { preload: join11(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
17218
+ webPreferences: { preload: join12(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
17169
17219
  });
17170
- void win.loadFile(join11(here, "..", "renderer", "index.html"));
17220
+ void win.loadFile(join12(here, "..", "renderer", "index.html"));
17171
17221
  }
17172
17222
  var host = null;
17173
17223
  app.whenReady().then(() => {
@@ -17236,7 +17286,7 @@ ipcMain.handle("paigy:agent", async (_e, name) => {
17236
17286
  });
17237
17287
  ipcMain.handle("paigy:agent-log", (_e, name) => {
17238
17288
  try {
17239
- const all = readFileSync6(join11(homedir11(), ".paigy", "host.log"), "utf8").split("\n");
17289
+ const all = readFileSync7(join12(homedir11(), ".paigy", "host.log"), "utf8").split("\n");
17240
17290
  const mine = all.filter((l) => l.includes(`[${name}]`) || /[▶✖◉⚠]/.test(l) && l.includes(name));
17241
17291
  return mine.slice(-500).join("\n");
17242
17292
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/harness",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
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",