@paigy/harness 0.3.30 → 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 +380 -112
  3. package/dist/main.js +315 -68
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -22793,14 +22793,14 @@ function sessionId() {
22793
22793
  async function reach(url, init) {
22794
22794
  return send(url, init, sessionId());
22795
22795
  }
22796
- function reachAs(session) {
22797
- return (url, init) => send(url, init, session);
22796
+ function reachAs(session, instance) {
22797
+ return (url, init) => send(url, init, session, instance);
22798
22798
  }
22799
- async function send(url, init, session) {
22799
+ async function send(url, init, session, instance = envSession("PAIGY_INSTANCE_ID") ?? INSTANCE_ID) {
22800
22800
  try {
22801
22801
  const headers = {
22802
22802
  ...init?.headers,
22803
- "x-paigy-instance": INSTANCE_ID,
22803
+ "x-paigy-instance": instance,
22804
22804
  "x-paigy-session": session
22805
22805
  };
22806
22806
  return await fetch(url, { ...init, headers, dispatcher: await proxy() });
@@ -23749,6 +23749,9 @@ function readToken(agent2 = agentName()) {
23749
23749
  if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
23750
23750
  return readTokenFile()[agent2]?.access_token ?? "";
23751
23751
  }
23752
+ function readSlot(agent2) {
23753
+ return readTokenFile()[agent2]?.access_token ?? "";
23754
+ }
23752
23755
  function listSlots() {
23753
23756
  return Object.keys(readTokenFile());
23754
23757
  }
@@ -23831,11 +23834,16 @@ async function updateGoal(goalId, input, opts = {}) {
23831
23834
  if (!res.ok) await fail("update_goal", res);
23832
23835
  return await res.json();
23833
23836
  }
23834
- async function hatch(name, voice = null) {
23835
- const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/hatch`, {
23837
+ async function hatch(name, voice = null, opts = {}) {
23838
+ const res = await ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/hatch`, {
23836
23839
  method: "POST",
23837
23840
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
23838
- body: JSON.stringify({ name, voice })
23841
+ body: JSON.stringify({
23842
+ name,
23843
+ voice,
23844
+ ...opts.brief ? { brief: opts.brief } : {},
23845
+ ...opts.parentGoalId ? { parentGoalId: opts.parentGoalId } : {}
23846
+ })
23839
23847
  }));
23840
23848
  if (!res.ok) throw new Error(`hatch failed: ${res.status} ${await res.text()}`);
23841
23849
  return await res.json();
@@ -23858,6 +23866,22 @@ async function claimSessions(opts = {}) {
23858
23866
  if (!res.ok) throw new Error(`claim_sessions failed: ${res.status}`);
23859
23867
  return (await res.json()).sessions;
23860
23868
  }
23869
+ async function claimSessionEnds(opts = {}) {
23870
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/ends/claim`, {
23871
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
23872
+ // the HOST's identity
23873
+ }));
23874
+ if (!res.ok) throw new Error(`claim_session_ends failed: ${res.status}`);
23875
+ return (await res.json()).ends ?? [];
23876
+ }
23877
+ async function finishSessionEnd(endId, result, opts = {}) {
23878
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/ends/${encodeURIComponent(endId)}`, {
23879
+ method: "POST",
23880
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
23881
+ body: JSON.stringify({ result })
23882
+ }));
23883
+ if (!res.ok) await fail("finish_session_end", res);
23884
+ }
23861
23885
  async function recordDecision(decision, opts = {}) {
23862
23886
  const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/host/decisions`, {
23863
23887
  method: "POST",
@@ -23964,7 +23988,9 @@ async function contact(input, opts = {}) {
23964
23988
  // this cannot see.
23965
23989
  repo: repoFromRemote(a.repo) ?? a.repo ?? currentRepo() ?? void 0,
23966
23990
  ask: a.ask,
23967
- options: a.options ?? []
23991
+ options: a.options ?? [],
23992
+ ...a.select ? { select: a.select } : {},
23993
+ ...a.answers ? { answers: a.answers } : {}
23968
23994
  }));
23969
23995
  const res = await ensureAuthed(await send22(`${BACKEND_URL}/api/goals/contact`, {
23970
23996
  method: "POST",
@@ -24186,7 +24212,7 @@ async function subscribeWake(onNudge, opts = {}) {
24186
24212
  }
24187
24213
  };
24188
24214
  }
24189
- var import_undici, require2, BACKEND_URL, NETWORK_MSG, PROXY_ENV, agent, INSTANCE_ID, WORKSPACE_ID, envSession, lineage, ignoreOverride, defaultOptions, getDefaultOptions, getRefs, getRelativePath, parseCatchDef, integerDateParser, isJsonSchema7AllOfType, emojiRegex2, zodPatterns, ALPHA_NUMERIC, primitiveMappings, asAnyOf, parseOptionalDef, parsePipelineDef, parseReadonlyDef, selectParser, get$ref, addMeta, zodToJsonSchema, OPTIONS_MIN, OPTIONS_MAX, OptionSchema, OptionInputSchema, NIGHT, AskInputSchema, StartContactSchema, ContactSchema, CONTACT_SCHEMA, CONTACT_DESCRIPTION, CreateGoalSchema, CreateGoalToolSchema, CREATE_GOAL_DESCRIPTION, UpdateGoalSchema, UpdateGoalToolSchema, ClaimGoalSchema, GetGoalSchema, GET_GOAL_DESCRIPTION, UPDATE_GOAL_DESCRIPTION, CLAIM_GOAL_DESCRIPTION, CHECK_REPLIES_DESCRIPTION, CheckRepliesSchema, AGENT_TOOLS, AGENT_TOOL_NAMES, LIVE_MS, WORKING_MS, ContextSchema, ParticipantSchema, TransformSchema, VisualSchema, NotifyLevelSchema, SelectShapeSchema, ReceiptEventSchema, AttentionSchema, NotifyRequestFields, NotifyRequestSchema, NotifyStatusSchema, AgentStateSchema, TurnSchema, UserAnswerSchema, IntentSchema, RideAlongSchema, AwaitItemSchema, VoiceKeySchema, AgendaTurnSchema, CLAIM_STALE_MS, InboxItemSchema, APNS_TOKEN_RE, PushTokenSchema, MissedCallSchema, clock, QUIET, BrokerTuningSchema, UserSettingsSchema, HistoryWorkSchema, HistoryEntrySchema, HistoryPageSchema, ACTIVITY_LINES, ACTIVITY_LINE_MAX, AgentActivitySchema, ConnectionSummarySchema, LedgerItemSchema, AgentLedgerSchema, ReassignResultSchema, MoveRingSchema, MoveSchema, QueueQuestionSchema, QueueReplySchema, QueueItemSchema, COLD_AFTER_MS, NoteSourceSchema, NoteStatusSchema, NoteRepeatSchema, DecisionSchema, NoteSchema, TriageItemSchema, TriageAssignmentSchema, TriageStatusSchema, SubmitTriageSchema, TriageProposalSchema, AcceptTriageSchema, AcceptTriageResultSchema, DeliveryModeSchema, WAKE_EVENT, wakeChannel, RegisterDeliverySchema, OAuthStartSchema, DeliveryConfigSchema, HostDecisionSchema, WakeNudgeSchema, PairingStatusSchema, DeviceCodeSchema, DeviceInfoSchema, DeviceTokenSchema, SupportRequestSchema, NotificationFeedbackKindSchema, SlimOptionSchema, QuestionRowSchema, WorkStateSchema, WorkRowSchema, ComputerRowSchema, AgentRowSchema, SnapshotSchema, CallRecapSchema, TOKEN_PATH, KEY_PATH, sleep, UnpairedError, RevokedError, ApiError, tokenOverride, authToken, AWAIT_WINDOW_MS, asked, NOT_SENT, HEARTBEAT_MS, RETRY_MS, JOIN, JOIN_TIMEOUT_MS, lines;
24215
+ var import_undici, require2, CODEX_ENV, BACKEND_URL, NETWORK_MSG, PROXY_ENV, agent, INSTANCE_ID, WORKSPACE_ID, envSession, lineage, ignoreOverride, defaultOptions, getDefaultOptions, getRefs, getRelativePath, parseCatchDef, integerDateParser, isJsonSchema7AllOfType, emojiRegex2, zodPatterns, ALPHA_NUMERIC, primitiveMappings, asAnyOf, parseOptionalDef, parsePipelineDef, parseReadonlyDef, selectParser, get$ref, addMeta, zodToJsonSchema, OPTIONS_MIN, OPTIONS_MAX, OptionSchema, OptionInputSchema, NIGHT, AskInputSchema, StartContactSchema, ContactSchema, CONTACT_SCHEMA, CONTACT_DESCRIPTION, CreateGoalSchema, CreateGoalToolSchema, CREATE_GOAL_DESCRIPTION, UpdateGoalSchema, UpdateGoalToolSchema, ClaimGoalSchema, GetGoalSchema, GET_GOAL_DESCRIPTION, UPDATE_GOAL_DESCRIPTION, CLAIM_GOAL_DESCRIPTION, CHECK_REPLIES_DESCRIPTION, CheckRepliesSchema, AGENT_TOOLS, AGENT_TOOL_NAMES, LIVE_MS, WORKING_MS, ContextSchema, ParticipantSchema, TransformSchema, VisualSchema, NotifyLevelSchema, SelectShapeSchema, ReceiptEventSchema, AttentionSchema, NotifyRequestFields, NotifyRequestSchema, NotifyStatusSchema, AgentStateSchema, TurnSchema, UserAnswerSchema, IntentSchema, RideAlongSchema, AwaitItemSchema, VoiceKeySchema, AgendaTurnSchema, CLAIM_STALE_MS, InboxItemSchema, APNS_TOKEN_RE, PushTokenSchema, MissedCallSchema, clock, QUIET, BrokerTuningSchema, UserSettingsSchema, HistoryWorkSchema, HistoryEntrySchema, HistoryPageSchema, ACTIVITY_LINES, ACTIVITY_LINE_MAX, AgentActivitySchema, ConnectionSummarySchema, LedgerItemSchema, AgentLedgerSchema, ReassignResultSchema, MoveRingSchema, MoveSchema, QueueQuestionSchema, QueueReplySchema, QueueItemSchema, COLD_AFTER_MS, NoteSourceSchema, NoteStatusSchema, NoteRepeatSchema, DecisionSchema, NoteSchema, TriageItemSchema, TriageAssignmentSchema, TriageStatusSchema, SubmitTriageSchema, TriageProposalSchema, AcceptTriageSchema, AcceptTriageResultSchema, DeliveryModeSchema, WAKE_EVENT, wakeChannel, RegisterDeliverySchema, OAuthStartSchema, DeliveryConfigSchema, HostDecisionSchema, WakeNudgeSchema, PairingStatusSchema, DeviceCodeSchema, DeviceInfoSchema, DeviceTokenSchema, SupportRequestSchema, NotificationFeedbackKindSchema, SlimOptionSchema, QuestionRowSchema, WorkStateSchema, WorkRowSchema, ComputerRowSchema, AgentRowSchema, SnapshotSchema, CallRecapSchema, TOKEN_PATH, KEY_PATH, sleep, UnpairedError, RevokedError, ApiError, tokenOverride, authToken, AWAIT_WINDOW_MS, asked, NOT_SENT, HEARTBEAT_MS, RETRY_MS, JOIN, JOIN_TIMEOUT_MS, lines;
24190
24216
  var init_dist = __esm({
24191
24217
  "../../packages/sdk/dist/index.js"() {
24192
24218
  "use strict";
@@ -24199,6 +24225,7 @@ var init_dist = __esm({
24199
24225
  init_zod();
24200
24226
  import_undici = __toESM(require_undici(), 1);
24201
24227
  require2 = __sdkCreateRequire(import.meta.url);
24228
+ CODEX_ENV = ["CODEX_THREAD_ID", "CODEX_SESSION_ID", "PAIGY_TOKEN", "PAIGY_SESSION_ID", "PAIGY_HARNESS", "PAIGY_INSTANCE_ID"];
24202
24229
  BACKEND_URL = process.env.PAIGY_BACKEND_URL ?? "https://paigy.ai";
24203
24230
  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.`;
24204
24231
  PROXY_ENV = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
@@ -24601,7 +24628,18 @@ var init_dist = __esm({
24601
24628
  ask: external_exports.string().trim().min(1).max(1e4).describe(
24602
24629
  "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
24630
  ),
24604
- options: external_exports.array(OptionInputSchema).min(1).max(6).optional()
24631
+ options: external_exports.array(OptionInputSchema).min(1).max(6).optional(),
24632
+ // YOUR VIEW OF ONE OR MANY, A HINT TO PAIGY'S READ (owner, 2026-10-02: "one and many makes sense...
24633
+ // make sure the brain that formats is aware of how it should work"). Three independent fixes went
24634
+ // out as "All three / 1 and 3 only / Just log it" because every card was pick-one. #2943 made this
24635
+ // field override the read; no agent sent it, so nothing changed. The read now decides each card's
24636
+ // shape (`goal/intake.ts` `answerOf`), and this reaches it as the agent's view, weighed, not obeyed.
24637
+ select: external_exports.enum(["one", "many"]).optional().describe(
24638
+ `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.`
24639
+ ),
24640
+ answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
24641
+ "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."
24642
+ )
24605
24643
  }).strict();
24606
24644
  StartContactSchema = external_exports.object({
24607
24645
  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 +24648,7 @@ var init_dist = __esm({
24610
24648
  }).strict();
24611
24649
  ContactSchema = external_exports.union([StartContactSchema, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
24612
24650
  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.";
24651
+ 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.";
24614
24652
  CreateGoalSchema = external_exports.object({
24615
24653
  outcome: external_exports.string().trim().min(1).max(1e4),
24616
24654
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -24633,7 +24671,7 @@ var init_dist = __esm({
24633
24671
  CreateGoalToolSchema = CreateGoalSchema.extend({
24634
24672
  idempotencyKey: CreateGoalSchema.shape.idempotencyKey.optional().describe("Optional. One is minted per call; pass your own only so a retry lands on the same Goal.")
24635
24673
  }).strict();
24636
- 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.';
24674
+ 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.';
24637
24675
  UpdateGoalSchema = external_exports.object({
24638
24676
  revision: external_exports.number().int().positive(),
24639
24677
  changes: external_exports.object({
@@ -24669,12 +24707,13 @@ var init_dist = __esm({
24669
24707
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
24670
24708
  diagnose: external_exports.boolean().optional()
24671
24709
  }).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.`;
24674
- 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.";
24710
+ 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.";
24711
+ 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.`;
24712
+ 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).";
24675
24713
  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
24714
  CheckRepliesSchema = external_exports.object({}).strict();
24677
24715
  AGENT_TOOLS = [
24716
+ { 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) },
24678
24717
  { name: "contact", description: CONTACT_DESCRIPTION, inputSchema: CONTACT_SCHEMA },
24679
24718
  { name: "check_replies", description: CHECK_REPLIES_DESCRIPTION, inputSchema: mcpInputSchema(CheckRepliesSchema) },
24680
24719
  { name: "create_goal", description: CREATE_GOAL_DESCRIPTION, inputSchema: mcpInputSchema(CreateGoalToolSchema) },
@@ -24684,7 +24723,7 @@ var init_dist = __esm({
24684
24723
  ];
24685
24724
  AGENT_TOOL_NAMES = AGENT_TOOLS.map((t) => t.name);
24686
24725
  LIVE_MS = 3 * 6e4;
24687
- WORKING_MS = 30 * 6e4;
24726
+ WORKING_MS = 60 * 6e4;
24688
24727
  ContextSchema = external_exports.object({
24689
24728
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
24690
24729
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -25036,7 +25075,13 @@ var init_dist = __esm({
25036
25075
  /** Whether the walk WAITS for an answer before moving on. Absent = derived as today
25037
25076
  * (a question blocks, context flows). blocking:false on a question = ask and move
25038
25077
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
25039
- blocking: external_exports.boolean().optional()
25078
+ blocking: external_exports.boolean().optional(),
25079
+ /** SPOKEN ONLY IF THEY SAY NOTHING (owner, 2026-10-01, call 812de935: "you're gonna re-ask, but it
25080
+ * shouldn't be the same words … more like, hey, are you still there, or are you able to answer, or
25081
+ * would you need more information"). The walk holds this turn out of its queue; at the queue's end it
25082
+ * listens for the last word, and only if that listen is silent is this turn said and asked. If they
25083
+ * speak, it is dropped and their words are taken like any reply. */
25084
+ ifSilent: external_exports.boolean().optional()
25040
25085
  });
25041
25086
  CLAIM_STALE_MS = 30 * 6e4;
25042
25087
  InboxItemSchema = external_exports.object({
@@ -25237,12 +25282,13 @@ var init_dist = __esm({
25237
25282
  * they are an enum on a live column across every account, and the rename is a rename of
25238
25283
  * what people read (owner, 2026-09-30). */
25239
25284
  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(),
25285
+ /** `silentPush` lived here until #2813 and is now GONE, field and column both. It was kept as an
25286
+ * optional long after DateNight stopped reading it, on the theory that a phone on an older
25287
+ * bundle PATCHing the whole settings object would be REFUSED for sending a key we had stopped
25288
+ * wanting. That theory was wrong about this schema: these are plain `z.object`s with no
25289
+ * `.strict()` anywhere in this file, and zod STRIPS unknown keys rather than rejecting them, so
25290
+ * an old bundle's `silentPush` is accepted and ignored. Worth remembering before keeping the
25291
+ * next dead field for the same reason. */
25246
25292
  /** Opt-in (default false) to using your content to improve Paigy and train models. */
25247
25293
  improveConsent: external_exports.boolean(),
25248
25294
  missedCall: MissedCallSchema.default("backoff_standard"),
@@ -25376,7 +25422,8 @@ var init_dist = __esm({
25376
25422
  });
25377
25423
  LedgerItemSchema = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
25378
25424
  AgentLedgerSchema = external_exports.object({
25379
- agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
25425
+ /** Null when the agent has not named itself yet — never a placeholder (owner, 2026-10-01). */
25426
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string().nullable(), revokedAt: external_exports.string().nullable() }),
25380
25427
  /** Its own questions you have not answered. */
25381
25428
  asks: external_exports.array(LedgerItemSchema),
25382
25429
  /** Its questions you answered that nobody acted on — still owed to somebody. */
@@ -25543,7 +25590,7 @@ var init_dist = __esm({
25543
25590
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
25544
25591
  * answer to a question, a voice note filed as work. Null when the only hands on it have been its
25545
25592
  * 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
25593
+ * 20261001030911 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
25547
25594
  * Work tab orders as it did before the migration rather than throwing on the missing key.
25548
25595
  *
25549
25596
  * It is a FACT, not a reconstruction: `latest` holds one Entry, so an agent's progress note a
@@ -25860,7 +25907,9 @@ var init_dist = __esm({
25860
25907
  * summarized like a pre-made option is"), so the row scans like a chosen option and their
25861
25908
  * own words stay under it. Absent on every line stored before the read wrote them, and on
25862
25909
  * 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() }))
25910
+ /** `about` is the request the line answered (its question), null for words that answered none —
25911
+ * the key the screen groups on, so one question is one row however many times it was answered. */
25912
+ 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
25913
  })),
25865
25914
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
25866
25915
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
@@ -31041,9 +31090,9 @@ init_catalog();
31041
31090
 
31042
31091
  // src/cli-args.ts
31043
31092
  var TRIAGE_VIA = ["ollama", "claude", "codex", "agy"];
31044
- var USAGE = "usage: paigy-harness setup | pair | host [--grant DIR]\u2026 | service | enable-tools [--scope user|project] | hatch NAME [--voice KEY] [--slot SLOT] | handoff | triage [--via ollama|claude|codex|agy] [--json] [--apply [--yes-close] [--yes-stale] [--yes-assign]] | [--harness claude|codex|agy] [--mode bypass|ask] [--cwd DIR] [--identity NAME] [--grace SECONDS] [--doctor] PROMPT\u2026";
31093
+ var USAGE = "usage: paigy-harness setup | pair | host [--grant DIR]\u2026 | service | enable-tools [--scope user|project] | hatch NAME [--voice KEY] [--slot SLOT] | launch NAME [--under GOAL] [--voice KEY] [--harness \u2026] [--mode \u2026] [--cwd DIR] BRIEF\u2026 | handoff | triage [--via ollama|claude|codex|agy] [--json] [--apply [--yes-close] [--yes-stale] [--yes-assign]] | [--harness claude|codex|agy] [--mode bypass|ask] [--cwd DIR] [--grace SECONDS] [--doctor] PROMPT\u2026";
31045
31094
  function parseArgs(argv) {
31046
- const args = { harness: "claude", mode: "bypass", cwd: process.cwd(), doctor: false, help: false, host: false, pair: false, service: false, setup: false, enableTools: false, scope: "user", grant: [], hatch: null, voice: null, slot: null, identity: null, graceSeconds: 90, prompt: "", handoff: false, triage: false, via: null, json: false, apply: false, yesClose: false, yesStale: false, yesAssign: false };
31095
+ const args = { harness: "claude", mode: "bypass", cwd: process.cwd(), doctor: false, help: false, host: false, pair: false, service: false, setup: false, enableTools: false, scope: "user", grant: [], hatch: null, voice: null, slot: null, launch: null, under: null, graceSeconds: 90, prompt: "", handoff: false, triage: false, via: null, json: false, apply: false, yesClose: false, yesStale: false, yesAssign: false };
31047
31096
  const words2 = [];
31048
31097
  for (let i = 0; i < argv.length; i++) {
31049
31098
  const arg = argv[i];
@@ -31114,9 +31163,13 @@ function parseArgs(argv) {
31114
31163
  } else if (arg === "--slot") {
31115
31164
  args.slot = next() ?? null;
31116
31165
  } else if (arg === "--identity") {
31166
+ return { error: '--identity is gone: start a helper with `paigy-harness launch NAME "brief"`, which gives it its own session' };
31167
+ } else if (arg === "launch") {
31117
31168
  const v = next();
31118
- if (!v) return { error: "--identity needs a hatched agent's name" };
31119
- args.identity = v;
31169
+ if (!v) return { error: 'launch needs a name: paigy-harness launch "Deploy check" "brief"' };
31170
+ args.launch = v;
31171
+ } else if (arg === "--under") {
31172
+ args.under = next() ?? null;
31120
31173
  } else if (arg?.startsWith("--")) {
31121
31174
  return { error: `unknown flag ${arg}` };
31122
31175
  } else if (arg) {
@@ -31125,6 +31178,7 @@ function parseArgs(argv) {
31125
31178
  }
31126
31179
  args.prompt = words2.join(" ");
31127
31180
  if (args.help) return args;
31181
+ if (args.launch && !args.prompt) return { error: 'launch needs a brief: paigy-harness launch "Deploy check" "brief"' };
31128
31182
  if (!args.doctor && !args.handoff && !args.hatch && !args.host && !args.pair && !args.service && !args.setup && !args.enableTools && !args.triage && !args.prompt) return { error: "a prompt is required (or setup / --doctor / pair / host / service / enable-tools / hatch NAME / handoff / triage)" };
31129
31183
  if ((args.yesClose || args.yesStale || args.yesAssign) && !args.apply)
31130
31184
  return { error: "--yes-close/--yes-stale/--yes-assign only apply with --apply" };
@@ -31157,20 +31211,23 @@ function onEnter(input, onLine) {
31157
31211
  // src/cli.ts
31158
31212
  init_dist();
31159
31213
  var import_qrcode = __toESM(require_lib(), 1);
31214
+ import { randomUUID as randomUUID7 } from "crypto";
31160
31215
  import { hostname } from "os";
31161
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
31216
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
31162
31217
  import { execFile as execFile2, execSync } from "child_process";
31163
31218
  import { homedir as homedir10 } from "os";
31164
- import { join as join10 } from "path";
31219
+ import { join as join11 } from "path";
31165
31220
 
31166
31221
  // src/run.ts
31167
31222
  init_dist();
31223
+ import { randomUUID as randomUUID6 } from "crypto";
31168
31224
 
31169
31225
  // src/harness/session.ts
31226
+ init_dist();
31170
31227
  import { spawn } from "child_process";
31171
- import { existsSync as existsSync3 } from "fs";
31228
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
31172
31229
  import { homedir as homedir4 } from "os";
31173
- import { resolve, delimiter as delimiter2 } from "path";
31230
+ import { resolve, delimiter as delimiter2, join as join5 } from "path";
31174
31231
 
31175
31232
  // src/harness/acp.ts
31176
31233
  var none = { events: [], writes: [] };
@@ -31544,6 +31601,21 @@ function startSession(opts) {
31544
31601
  if (!existsSync3(cwd)) {
31545
31602
  queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
31546
31603
  }
31604
+ const codexConfig = opts.harness === "codex" ? JSON.parse(process.env.CODEX_CONFIG || "{}") : null;
31605
+ if (codexConfig) {
31606
+ const key = "mcp_servers.paigy.env_vars";
31607
+ const configPath = join5(process.env.CODEX_HOME || join5(homedir4(), ".codex"), "config.toml");
31608
+ const saved = existsSync3(configPath) ? readFileSync3(configPath, "utf8") : "";
31609
+ const registered = /\[mcp_servers\.paigy\][\s\S]*?(?=\n\[|$)/.exec(saved)?.[0];
31610
+ const configured = codexConfig["mcp_servers.paigy.command"] || codexConfig["mcp_servers.paigy.url"] || codexConfig.mcp_servers?.paigy?.command || codexConfig.mcp_servers?.paigy?.url;
31611
+ if (!configured && !registered) {
31612
+ codexConfig["mcp_servers.paigy.command"] = "npx";
31613
+ codexConfig["mcp_servers.paigy.args"] = ["-y", "@paigy/mcp@latest"];
31614
+ }
31615
+ const savedVars = registered?.match(/env_vars\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? "";
31616
+ const inherited = [...savedVars.matchAll(/["']([A-Za-z_][A-Za-z0-9_]*)["']/g)].map((match) => match[1]);
31617
+ codexConfig[key] = [.../* @__PURE__ */ new Set([...inherited, ...codexConfig[key] ?? codexConfig.mcp_servers?.paigy?.env_vars ?? [], ...CODEX_ENV])];
31618
+ }
31547
31619
  const child = (opts.spawnFn ?? spawn)(opts.bin ?? ADAPTER_BIN[opts.harness], [], {
31548
31620
  cwd,
31549
31621
  // The adapter shells out to its vendor CLI (`claude`, `codex`), and a GUI- or
@@ -31554,7 +31626,9 @@ function startSession(opts) {
31554
31626
  ...process.env,
31555
31627
  PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
31556
31628
  ...opts.token ? { PAIGY_TOKEN: opts.token } : {},
31557
- ...opts.resume ?? opts.session ? { PAIGY_SESSION_ID: opts.resume ?? opts.session } : {},
31629
+ ...opts.instance ? { PAIGY_INSTANCE_ID: opts.instance } : {},
31630
+ ...codexConfig ? { CODEX_CONFIG: JSON.stringify(codexConfig) } : {},
31631
+ ...opts.session ?? opts.resume ? { PAIGY_SESSION_ID: opts.session ?? opts.resume } : {},
31558
31632
  // A PUMP DRIVES THIS PROCESS (2026-09-22, #2265): every run `runHarness` starts has the
31559
31633
  // bridge's pump typing answers in, so nothing inside it may start a second delivery path.
31560
31634
  // Exported on EVERY spawn — `PAIGY_SESSION_ID` above is the conversation to resume, and a
@@ -32981,7 +33055,18 @@ var AskInputSchema2 = external_exports.object({
32981
33055
  ask: external_exports.string().trim().min(1).max(1e4).describe(
32982
33056
  "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
33057
  ),
32984
- options: external_exports.array(OptionInputSchema2).min(1).max(6).optional()
33058
+ options: external_exports.array(OptionInputSchema2).min(1).max(6).optional(),
33059
+ // YOUR VIEW OF ONE OR MANY, A HINT TO PAIGY'S READ (owner, 2026-10-02: "one and many makes sense...
33060
+ // make sure the brain that formats is aware of how it should work"). Three independent fixes went
33061
+ // out as "All three / 1 and 3 only / Just log it" because every card was pick-one. #2943 made this
33062
+ // field override the read; no agent sent it, so nothing changed. The read now decides each card's
33063
+ // shape (`goal/intake.ts` `answerOf`), and this reaches it as the agent's view, weighed, not obeyed.
33064
+ select: external_exports.enum(["one", "many"]).optional().describe(
33065
+ `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.`
33066
+ ),
33067
+ answers: external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/).optional().describe(
33068
+ "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."
33069
+ )
32985
33070
  }).strict();
32986
33071
  var StartContactSchema2 = external_exports.object({
32987
33072
  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 +33075,7 @@ var StartContactSchema2 = external_exports.object({
32990
33075
  }).strict();
32991
33076
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
32992
33077
  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.";
33078
+ 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.";
32994
33079
  var CreateGoalSchema2 = external_exports.object({
32995
33080
  outcome: external_exports.string().trim().min(1).max(1e4),
32996
33081
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -33013,7 +33098,7 @@ var CreateGoalSchema2 = external_exports.object({
33013
33098
  var CreateGoalToolSchema2 = CreateGoalSchema2.extend({
33014
33099
  idempotencyKey: CreateGoalSchema2.shape.idempotencyKey.optional().describe("Optional. One is minted per call; pass your own only so a retry lands on the same Goal.")
33015
33100
  }).strict();
33016
- 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.';
33101
+ 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.';
33017
33102
  var UpdateGoalSchema2 = external_exports.object({
33018
33103
  revision: external_exports.number().int().positive(),
33019
33104
  changes: external_exports.object({
@@ -33049,12 +33134,13 @@ var GetGoalSchema2 = external_exports.object({
33049
33134
  * between two of them named. Off by default; `docs/model/goal/diagnose-design.md`. */
33050
33135
  diagnose: external_exports.boolean().optional()
33051
33136
  }).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.`;
33054
- 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.";
33137
+ 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.";
33138
+ 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.`;
33139
+ 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).";
33055
33140
  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
33141
  var CheckRepliesSchema2 = external_exports.object({}).strict();
33057
33142
  var AGENT_TOOLS2 = [
33143
+ { 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) },
33058
33144
  { name: "contact", description: CONTACT_DESCRIPTION2, inputSchema: CONTACT_SCHEMA2 },
33059
33145
  { name: "check_replies", description: CHECK_REPLIES_DESCRIPTION2, inputSchema: mcpInputSchema2(CheckRepliesSchema2) },
33060
33146
  { name: "create_goal", description: CREATE_GOAL_DESCRIPTION2, inputSchema: mcpInputSchema2(CreateGoalToolSchema2) },
@@ -33079,7 +33165,7 @@ function entryWords(entry) {
33079
33165
  return entry.sources.map((source) => source.text).join("\n");
33080
33166
  }
33081
33167
  var LIVE_MS2 = 3 * 6e4;
33082
- var WORKING_MS2 = 30 * 6e4;
33168
+ var WORKING_MS2 = 60 * 6e4;
33083
33169
  var ContextSchema2 = external_exports.object({
33084
33170
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
33085
33171
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -33431,7 +33517,13 @@ var AgendaTurnSchema2 = external_exports.object({
33431
33517
  /** Whether the walk WAITS for an answer before moving on. Absent = derived as today
33432
33518
  * (a question blocks, context flows). blocking:false on a question = ask and move
33433
33519
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
33434
- blocking: external_exports.boolean().optional()
33520
+ blocking: external_exports.boolean().optional(),
33521
+ /** SPOKEN ONLY IF THEY SAY NOTHING (owner, 2026-10-01, call 812de935: "you're gonna re-ask, but it
33522
+ * shouldn't be the same words … more like, hey, are you still there, or are you able to answer, or
33523
+ * would you need more information"). The walk holds this turn out of its queue; at the queue's end it
33524
+ * listens for the last word, and only if that listen is silent is this turn said and asked. If they
33525
+ * speak, it is dropped and their words are taken like any reply. */
33526
+ ifSilent: external_exports.boolean().optional()
33435
33527
  });
33436
33528
  var CLAIM_STALE_MS2 = 30 * 6e4;
33437
33529
  var InboxItemSchema2 = external_exports.object({
@@ -33632,12 +33724,13 @@ var UserSettingsSchema2 = external_exports.object({
33632
33724
  * they are an enum on a live column across every account, and the rename is a rename of
33633
33725
  * what people read (owner, 2026-09-30). */
33634
33726
  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(),
33727
+ /** `silentPush` lived here until #2813 and is now GONE, field and column both. It was kept as an
33728
+ * optional long after DateNight stopped reading it, on the theory that a phone on an older
33729
+ * bundle PATCHing the whole settings object would be REFUSED for sending a key we had stopped
33730
+ * wanting. That theory was wrong about this schema: these are plain `z.object`s with no
33731
+ * `.strict()` anywhere in this file, and zod STRIPS unknown keys rather than rejecting them, so
33732
+ * an old bundle's `silentPush` is accepted and ignored. Worth remembering before keeping the
33733
+ * next dead field for the same reason. */
33641
33734
  /** Opt-in (default false) to using your content to improve Paigy and train models. */
33642
33735
  improveConsent: external_exports.boolean(),
33643
33736
  missedCall: MissedCallSchema2.default("backoff_standard"),
@@ -33771,7 +33864,8 @@ var ConnectionSummarySchema2 = external_exports.object({
33771
33864
  });
33772
33865
  var LedgerItemSchema2 = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
33773
33866
  var AgentLedgerSchema2 = external_exports.object({
33774
- agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
33867
+ /** Null when the agent has not named itself yet — never a placeholder (owner, 2026-10-01). */
33868
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string().nullable(), revokedAt: external_exports.string().nullable() }),
33775
33869
  /** Its own questions you have not answered. */
33776
33870
  asks: external_exports.array(LedgerItemSchema2),
33777
33871
  /** Its questions you answered that nobody acted on — still owed to somebody. */
@@ -33938,7 +34032,7 @@ var QueueItemSchema2 = external_exports.object({
33938
34032
  * the newest Entry on the Goal they wrote, of any kind — a line they added, a reply to a note, an
33939
34033
  * answer to a question, a voice note filed as work. Null when the only hands on it have been its
33940
34034
  * 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
34035
+ * 20261001030911 need not spell it — `work/list.ts`'s `yoursAt` keeps its other sources, so the
33942
34036
  * Work tab orders as it did before the migration rather than throwing on the missing key.
33943
34037
  *
33944
34038
  * It is a FACT, not a reconstruction: `latest` holds one Entry, so an agent's progress note a
@@ -34253,7 +34347,9 @@ var CallRecapSchema2 = external_exports.object({
34253
34347
  * summarized like a pre-made option is"), so the row scans like a chosen option and their
34254
34348
  * own words stay under it. Absent on every line stored before the read wrote them, and on
34255
34349
  * 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() }))
34350
+ /** `about` is the request the line answered (its question), null for words that answered none —
34351
+ * the key the screen groups on, so one question is one row however many times it was answered. */
34352
+ 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
34353
  })),
34258
34354
  unfiled: external_exports.array(external_exports.object({ lineId: external_exports.string().uuid(), words: external_exports.string(), atMs: external_exports.number() })),
34259
34355
  more: external_exports.object({ lines: external_exports.number(), entries: external_exports.number(), topics: external_exports.number() })
@@ -34467,9 +34563,13 @@ async function finish(result, state, rail, opts) {
34467
34563
  const progress = result?.trim().slice(0, 1e4) || void 0;
34468
34564
  let revision = goal.revision;
34469
34565
  try {
34470
- const held = await rail.claim(goal.id);
34566
+ const held = await rail.read(goal.id);
34471
34567
  revision = held.revision ?? revision;
34472
34568
  const owed = state.noted?.some((b) => b.goals.includes(goal.id));
34569
+ if (held.state === "done" || held.state === "cancelled") {
34570
+ if (!owed) state.goal = void 0;
34571
+ return;
34572
+ }
34473
34573
  if (!held.openDecisionNeedIds?.length && !owed) {
34474
34574
  await rail.update(goal.id, { revision, changes: { state: "done", ...progress ? { progress } : {} }, reason: "The agent finished its turn." });
34475
34575
  state.goal = void 0;
@@ -34558,11 +34658,16 @@ function beat() {
34558
34658
  }
34559
34659
  var BEAT_REPORT_MS = 10 * 6e4;
34560
34660
  var ended = (state) => state === "done" || state === "cancelled";
34561
- function unheard(brief, state, since = null, after = -1) {
34661
+ function unheard(brief, state, since = null, after = -1, reviewSince) {
34562
34662
  const seen = state.seen ??= /* @__PURE__ */ new Set();
34563
34663
  const words2 = [];
34664
+ const pendingAt = reviewSince ? Date.parse(reviewSince) : null;
34564
34665
  for (const [i, e] of (brief.entries ?? []).entries()) {
34565
34666
  if (seen.has(e.entryId) || !e.authorParticipant.startsWith("human:")) continue;
34667
+ if (pendingAt !== null && Date.parse(e.createdAt) < pendingAt) {
34668
+ seen.add(e.entryId);
34669
+ continue;
34670
+ }
34566
34671
  if (i <= after) {
34567
34672
  seen.add(e.entryId);
34568
34673
  continue;
@@ -34573,6 +34678,12 @@ function unheard(brief, state, since = null, after = -1) {
34573
34678
  const text = entryWords(e).trim();
34574
34679
  if (text) words2.push(text);
34575
34680
  }
34681
+ if (pendingAt !== null) for (const answer of brief.answers ?? []) {
34682
+ const key = `answer:${answer.decisionNeedId}`;
34683
+ if (seen.has(key) || Date.parse(answer.acceptedAt) < pendingAt) continue;
34684
+ seen.add(key);
34685
+ words2.push(`Answer to question ${answer.decisionNeedId}: ${JSON.stringify(answer.result)}`);
34686
+ }
34576
34687
  return words2;
34577
34688
  }
34578
34689
  function sinceTheWork(brief) {
@@ -34641,9 +34752,9 @@ async function gather(state, rail, held) {
34641
34752
  }
34642
34753
  }
34643
34754
  read.add(goalId);
34644
- const fresh = found.via === "waiting work" || found.via === "assigned" ? sinceTheWork(brief) : { after: -1 };
34755
+ const fresh = !brief.reviewSince && (found.via === "waiting work" || found.via === "assigned") ? sinceTheWork(brief) : { after: -1 };
34645
34756
  if (found.via === "assigned") announced.add(goalId);
34646
- heard.push({ goalId, ...found, brief, words: unheard(brief, state, since ?? null, fresh.after), ...fresh.note ? { note: fresh.note } : {} });
34757
+ heard.push({ goalId, ...found, brief, words: unheard(brief, state, brief.reviewSince ? null : since ?? null, fresh.after, brief.reviewSince), ...fresh.note ? { note: fresh.note } : {} });
34647
34758
  }
34648
34759
  for (const { goal, since } of moved) if (read.has(goal.goalId)) state.below.goals.set(goal.goalId, { since, at: goal.updatedAt ?? null });
34649
34760
  return { heard, listed };
@@ -34729,8 +34840,8 @@ async function acknowledge(goalId, brief, state, rail, log) {
34729
34840
  if (acked.has(goalId) && acked.get(goalId) === brief.revision) return;
34730
34841
  acked.set(goalId, brief.revision);
34731
34842
  try {
34732
- const claimed = await rail.claim(goalId);
34733
- 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." });
34843
+ if (brief.revision === void 0) throw new Error("review_revision_missing");
34844
+ 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." });
34734
34845
  acked.set(goalId, done.revision);
34735
34846
  if (state.goal?.id === goalId && done.revision !== void 0) state.goal.revision = done.revision;
34736
34847
  } catch (error) {
@@ -34767,25 +34878,26 @@ async function look(session, state, rail, opts) {
34767
34878
  brief2 = await rail.claim();
34768
34879
  if (!brief2.goalId || brief2.revision === void 0) {
34769
34880
  state.goal = { id: admitted.goalId, revision: admitted.revision };
34770
- send2(session, state, [HARNESS_INSTRUCTION, prompt].join("\n\n"));
34881
+ send2(session, state, [HARNESS_INSTRUCTION, named(admitted.goalId, void 0, { outcome: prompt }), prompt].join("\n\n"));
34771
34882
  return;
34772
34883
  }
34773
34884
  } else {
34774
34885
  return;
34775
34886
  }
34776
34887
  }
34777
- unheard(brief2, state);
34778
- if (ended(brief2.state)) await rail.update(brief2.goalId, { revision: brief2.revision, changes: { reviewed: true }, reason: "Typed into the session." });
34779
- else {
34888
+ const pendingWords = brief2.reviewSince ? unheard(brief2, state, null, -1, brief2.reviewSince) : null;
34889
+ if (!pendingWords) unheard(brief2, state);
34890
+ if (!ended(brief2.state)) {
34780
34891
  state.goal = { id: brief2.goalId, revision: brief2.revision };
34781
34892
  state.below = baseline(brief2.goalId, brief2.subtree);
34782
34893
  }
34783
34894
  const outcome2 = brief2.outcome?.trim() ?? "";
34784
- const said = (brief2.entries ?? []).map((e) => entryWords(e).trim()).filter((w) => w && w.toLowerCase() !== outcome2.toLowerCase());
34895
+ const said = (pendingWords ?? (brief2.entries ?? []).map((e) => entryWords(e).trim())).filter((w) => w && w.toLowerCase() !== outcome2.toLowerCase());
34785
34896
  const left = brief2.earlier ? brief2.earlier.omitted + brief2.earlier.shortened : 0;
34786
34897
  const earlier = left ? `(${left} earlier entries on this Goal are left out or shortened; get_goal with history: true reads them in full.)` : "";
34787
34898
  const fullText = [outcome2, earlier, ...said].filter(Boolean).join("\n\n");
34788
- send2(session, state, [HARNESS_INSTRUCTION, fullText].filter(Boolean).join("\n\n"));
34899
+ send2(session, state, [HARNESS_INSTRUCTION, named(brief2.goalId, void 0, brief2), fullText].filter(Boolean).join("\n\n"));
34900
+ if (ended(brief2.state)) await acknowledge(brief2.goalId, brief2, state, rail, opts?.log);
34789
34901
  return;
34790
34902
  }
34791
34903
  const brief = await rail.read(state.goal.id);
@@ -34794,6 +34906,7 @@ async function look(session, state, rail, opts) {
34794
34906
  const resolve3 = state.asks?.get(answer.requestEntryId);
34795
34907
  if (!resolve3) continue;
34796
34908
  state.asks.delete(answer.requestEntryId);
34909
+ (state.seen ??= /* @__PURE__ */ new Set()).add(`answer:${answer.decisionNeedId}`);
34797
34910
  resolve3(answer.result);
34798
34911
  }
34799
34912
  if (ended(brief.state)) {
@@ -34871,8 +34984,9 @@ function runHarness(opts) {
34871
34984
  const state = { resting: true };
34872
34985
  const clock4 = beat();
34873
34986
  let session = null;
34874
- const speaksAs = opts.resume ?? opts.session;
34875
- const rail = railFor({ token: opts.token, ...speaksAs ? { reach: reachAs(speaksAs) } : {} });
34987
+ const speaksAs = opts.session ?? opts.resume ?? sessionId();
34988
+ const instance = randomUUID6();
34989
+ const rail = railFor({ token: opts.token, reach: reachAs(speaksAs, instance) });
34876
34990
  let inbox;
34877
34991
  if (opts.harness === "claude") {
34878
34992
  try {
@@ -34928,6 +35042,7 @@ ${event.message}`, state, rail, { log: opts.log });
34928
35042
  }
34929
35043
  }
34930
35044
  session = startSession({
35045
+ instance,
34931
35046
  harness: opts.harness,
34932
35047
  cwd: opts.cwd,
34933
35048
  mode: opts.mode,
@@ -34970,9 +35085,9 @@ ${event.message}`, state, rail, { log: opts.log });
34970
35085
 
34971
35086
  // src/host.ts
34972
35087
  init_dist();
34973
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
35088
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
34974
35089
  import { homedir as homedir9 } from "os";
34975
- import { join as join9 } from "path";
35090
+ import { join as join10 } from "path";
34976
35091
 
34977
35092
  // src/broker.ts
34978
35093
  init_evidence();
@@ -34992,15 +35107,15 @@ function ago(iso, now) {
34992
35107
  init_catalog();
34993
35108
 
34994
35109
  // src/workspaces.ts
34995
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
34996
- import { dirname as dirname2, join as join5, resolve as resolve2 } from "path";
35110
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
35111
+ import { dirname as dirname2, join as join6, resolve as resolve2 } from "path";
34997
35112
  import { homedir as homedir6 } from "os";
34998
35113
  function workspacesFile() {
34999
- return join5(homedir6(), ".paigy", "workspaces.json");
35114
+ return join6(homedir6(), ".paigy", "workspaces.json");
35000
35115
  }
35001
35116
  function listWorkspaces(deps) {
35002
35117
  try {
35003
- const parsed = JSON.parse(readFileSync3(deps.file, "utf8"));
35118
+ const parsed = JSON.parse(readFileSync4(deps.file, "utf8"));
35004
35119
  return Array.isArray(parsed) ? parsed.filter((w) => typeof w === "string") : [];
35005
35120
  } catch {
35006
35121
  return [];
@@ -35029,8 +35144,8 @@ function resolveWakeDir(pinned, deps) {
35029
35144
  }
35030
35145
  function originOf(dir) {
35031
35146
  try {
35032
- if (!statSync3(join5(dir, ".git")).isDirectory()) return null;
35033
- const config = readFileSync3(join5(dir, ".git", "config"), "utf8");
35147
+ if (!statSync3(join6(dir, ".git")).isDirectory()) return null;
35148
+ const config = readFileSync4(join6(dir, ".git", "config"), "utf8");
35034
35149
  const section = config.split(/^\[/m).find((s) => /^remote\s+"origin"\]/.test(s));
35035
35150
  return section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim() ?? null;
35036
35151
  } catch {
@@ -35051,7 +35166,7 @@ function listRepos(deps) {
35051
35166
  }
35052
35167
  for (const name of children) {
35053
35168
  if (repos.length >= MAX_REPOS) return repos;
35054
- const path = join5(root, name);
35169
+ const path = join6(root, name);
35055
35170
  const remote = originOf(path);
35056
35171
  if (remote) repos.push({ path, remote });
35057
35172
  }
@@ -35062,9 +35177,9 @@ function listRepos(deps) {
35062
35177
  // src/holder.ts
35063
35178
  init_dist();
35064
35179
  import { execFileSync as execFileSync3 } from "child_process";
35065
- import { readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
35180
+ import { readFileSync as readFileSync5, readdirSync as readdirSync3 } from "fs";
35066
35181
  import { homedir as homedir7 } from "os";
35067
- import { join as join6 } from "path";
35182
+ import { join as join7 } from "path";
35068
35183
  function isAlive(pid) {
35069
35184
  try {
35070
35185
  process.kill(pid, 0);
@@ -35093,10 +35208,63 @@ function holds(pid, procStart, deps) {
35093
35208
  }
35094
35209
  return false;
35095
35210
  }
35211
+ function proven(pid, procStart, deps) {
35212
+ if (!deps.alive(pid) || typeof procStart !== "string" || !procStart.trim()) return false;
35213
+ const want = squash(procStart);
35214
+ return ["UTC", "local"].some((zone) => {
35215
+ const got = deps.started(pid, zone);
35216
+ return got !== null && squash(got) === want;
35217
+ });
35218
+ }
35219
+ function localHolderPids(deps = {}) {
35220
+ const dir = deps.dir ?? join7(process.env.CLAUDE_CONFIG_DIR?.trim() || join7(homedir7(), ".claude"), "sessions");
35221
+ const list = deps.list ?? ((d) => readdirSync3(d));
35222
+ const read = deps.read ?? ((f) => readFileSync5(f, "utf8"));
35223
+ const check = { alive: deps.alive ?? isAlive, started: deps.started ?? psStart };
35224
+ const held = /* @__PURE__ */ new Map();
35225
+ let files;
35226
+ try {
35227
+ files = list(dir);
35228
+ } catch {
35229
+ return held;
35230
+ }
35231
+ for (const file of files) {
35232
+ if (!/^\d+\.json$/.test(file)) continue;
35233
+ let row;
35234
+ try {
35235
+ row = JSON.parse(read(join7(dir, file)));
35236
+ } catch {
35237
+ continue;
35238
+ }
35239
+ const pid = typeof row.pid === "number" ? row.pid : Number(file.slice(0, -5));
35240
+ if (typeof row.sessionId !== "string" || !row.sessionId.trim() || !Number.isInteger(pid) || pid <= 0) continue;
35241
+ if (!holds(pid, row.procStart, check)) continue;
35242
+ const slot = sessionSlot(row.sessionId);
35243
+ held.set(slot, [...held.get(slot) ?? [], { pid, proven: proven(pid, row.procStart, check) }]);
35244
+ }
35245
+ return held;
35246
+ }
35247
+ function psParent(pid) {
35248
+ try {
35249
+ const out = execFileSync3("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", timeout: 2e3 }).trim();
35250
+ const n = Number(out);
35251
+ return Number.isInteger(n) && n > 0 ? n : null;
35252
+ } catch {
35253
+ return null;
35254
+ }
35255
+ }
35256
+ function descendsFrom(pid, ancestor, parent = psParent) {
35257
+ let at = pid;
35258
+ for (let i = 0; i < 32 && at !== null && at > 1; i += 1) {
35259
+ if (at === ancestor) return true;
35260
+ at = parent(at);
35261
+ }
35262
+ return false;
35263
+ }
35096
35264
  function localHolders(deps = {}) {
35097
- const dir = deps.dir ?? join6(process.env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir7(), ".claude"), "sessions");
35265
+ const dir = deps.dir ?? join7(process.env.CLAUDE_CONFIG_DIR?.trim() || join7(homedir7(), ".claude"), "sessions");
35098
35266
  const list = deps.list ?? ((d) => readdirSync3(d));
35099
- const read = deps.read ?? ((f) => readFileSync4(f, "utf8"));
35267
+ const read = deps.read ?? ((f) => readFileSync5(f, "utf8"));
35100
35268
  const check = { alive: deps.alive ?? isAlive, started: deps.started ?? psStart };
35101
35269
  const held = /* @__PURE__ */ new Map();
35102
35270
  let files;
@@ -35109,7 +35277,7 @@ function localHolders(deps = {}) {
35109
35277
  if (!/^\d+\.json$/.test(file)) continue;
35110
35278
  let row;
35111
35279
  try {
35112
- row = JSON.parse(read(join6(dir, file)));
35280
+ row = JSON.parse(read(join7(dir, file)));
35113
35281
  } catch {
35114
35282
  continue;
35115
35283
  }
@@ -35122,10 +35290,69 @@ function localHolders(deps = {}) {
35122
35290
  return held;
35123
35291
  }
35124
35292
 
35293
+ // src/end.ts
35294
+ var GRACE_MS = 5e3;
35295
+ async function endTerminal(pid, slot, deps) {
35296
+ try {
35297
+ deps.kill(pid, "SIGTERM");
35298
+ } catch (e) {
35299
+ return { ok: false, kind: "terminal", pid, why: `SIGTERM refused: ${e.message}`.slice(0, 200) };
35300
+ }
35301
+ const grace = deps.graceMs ?? GRACE_MS;
35302
+ for (let waited = 0; waited < grace; waited += 250) {
35303
+ if (!deps.alive(pid)) {
35304
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, pid, kind: "terminal", signal: "SIGTERM" })}`);
35305
+ return { ok: true, kind: "terminal", pid, signal: "SIGTERM" };
35306
+ }
35307
+ await deps.sleep(250);
35308
+ }
35309
+ try {
35310
+ deps.kill(pid, "SIGKILL");
35311
+ } catch (e) {
35312
+ return { ok: false, kind: "terminal", pid, why: `SIGKILL refused: ${e.message}`.slice(0, 200) };
35313
+ }
35314
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, pid, kind: "terminal", signal: "SIGKILL" })}`);
35315
+ return { ok: true, kind: "terminal", pid, signal: "SIGKILL" };
35316
+ }
35317
+ async function endSession(end, deps) {
35318
+ const slots = deps.slotsFor(end.agent);
35319
+ if (!slots.length) return { ok: false, why: "not_on_this_host" };
35320
+ let ended2 = null;
35321
+ if (end.which === "harness" || end.which === "any") {
35322
+ for (const slot of slots) {
35323
+ const run = deps.ownRun(slot);
35324
+ if (!run) continue;
35325
+ run.stop();
35326
+ deps.log(`session_ended ${JSON.stringify({ sessionId: slot, kind: "harness", signal: "stop" })}`);
35327
+ ended2 = { ok: true, kind: "harness", signal: "stop" };
35328
+ }
35329
+ if (end.which === "harness" && !ended2) return { ok: false, kind: "harness", why: "no_harness_copy_here" };
35330
+ }
35331
+ if (end.which === "terminal" || end.which === "any") {
35332
+ let terminal = null;
35333
+ let unproven = null;
35334
+ for (const slot of slots) {
35335
+ for (const { pid, proven: proven2 } of deps.pidsFor(slot).filter((p) => !deps.isOwn(p.pid))) {
35336
+ if (!proven2) {
35337
+ deps.log(`session_end_refused ${JSON.stringify({ sessionId: slot, pid, why: "no_proc_start" })}`);
35338
+ unproven = { ok: false, kind: "terminal", pid, why: "no_proc_start" };
35339
+ continue;
35340
+ }
35341
+ terminal = await endTerminal(pid, slot, deps);
35342
+ if (!terminal.ok) return terminal;
35343
+ }
35344
+ }
35345
+ if (terminal) return terminal;
35346
+ if (unproven) return unproven;
35347
+ if (end.which === "terminal") return { ok: false, kind: "terminal", why: "no_terminal_copy_here" };
35348
+ }
35349
+ return ended2 ?? { ok: false, why: "nothing_running_here" };
35350
+ }
35351
+
35125
35352
  // src/owner.ts
35126
35353
  import { closeSync as closeSync2, openSync as openSync2, readSync, readdirSync as readdirSync4 } from "fs";
35127
35354
  import { homedir as homedir8 } from "os";
35128
- import { join as join7 } from "path";
35355
+ import { join as join8 } from "path";
35129
35356
  var HEAD_BYTES = 64e3;
35130
35357
  function readHead(file) {
35131
35358
  const fd = openSync2(file, "r");
@@ -35141,7 +35368,7 @@ function ownerSession(slot, deps = {}) {
35141
35368
  const m = /^session:([0-9a-f]{8})$/.exec(slot);
35142
35369
  if (!m) return null;
35143
35370
  const prefix = m[1];
35144
- const root = deps.projectsDir ?? join7(homedir8(), ".claude", "projects");
35371
+ const root = deps.projectsDir ?? join8(homedir8(), ".claude", "projects");
35145
35372
  const list = deps.list ?? ((dir) => readdirSync4(dir));
35146
35373
  const head = deps.head ?? readHead;
35147
35374
  let folders;
@@ -35153,7 +35380,7 @@ function ownerSession(slot, deps = {}) {
35153
35380
  for (const folder of folders) {
35154
35381
  let files;
35155
35382
  try {
35156
- files = list(join7(root, folder));
35383
+ files = list(join8(root, folder));
35157
35384
  } catch {
35158
35385
  continue;
35159
35386
  }
@@ -35161,7 +35388,7 @@ function ownerSession(slot, deps = {}) {
35161
35388
  if (!file) continue;
35162
35389
  let text;
35163
35390
  try {
35164
- text = head(join7(root, folder, file));
35391
+ text = head(join8(root, folder, file));
35165
35392
  } catch {
35166
35393
  continue;
35167
35394
  }
@@ -35181,7 +35408,7 @@ function ownerSession(slot, deps = {}) {
35181
35408
  // src/update.ts
35182
35409
  import { execFile } from "child_process";
35183
35410
  import { createRequire } from "module";
35184
- import { dirname as dirname3, join as join8 } from "path";
35411
+ import { dirname as dirname3, join as join9 } from "path";
35185
35412
  import { promisify } from "util";
35186
35413
  var SERVICE_LABEL = "ai.paigy.harness";
35187
35414
  var VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -35208,7 +35435,7 @@ async function latestVersion() {
35208
35435
  }
35209
35436
  async function install(version) {
35210
35437
  const bin = dirname3(process.execPath);
35211
- await promisify(execFile)(join8(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
35438
+ await promisify(execFile)(join9(bin, "npm"), ["install", "-g", `@paigy/harness@${version}`], {
35212
35439
  env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, npm_config_update_notifier: "false" },
35213
35440
  timeout: 5 * 6e4
35214
35441
  });
@@ -35259,7 +35486,7 @@ function selfUpdater(deps) {
35259
35486
  }
35260
35487
 
35261
35488
  // src/host.ts
35262
- var HOST_FILE = join9(homedir9(), ".paigy", "host.json");
35489
+ var HOST_FILE = join10(homedir9(), ".paigy", "host.json");
35263
35490
  function wakeHarness(slot, saved, claudeTranscript) {
35264
35491
  if (slot === "Desktop") return void 0;
35265
35492
  if (saved !== null) return saved === "agy" || saved === "codex" || saved === "claude" ? saved : void 0;
@@ -35425,6 +35652,31 @@ function startHost(opts) {
35425
35652
  void refreshIdentities();
35426
35653
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
35427
35654
  }
35655
+ const ends = await claimSessionEnds(asHost).catch(() => []);
35656
+ for (const end of ends) {
35657
+ const result = await endSession(end, {
35658
+ slotsFor: (agent2) => listSlots().filter((slot) => slot !== "Desktop" && slotIdentity(slot).tokenId === agent2),
35659
+ pidsFor: (slot) => localHolderPids().get(slot) ?? [],
35660
+ ownRun: (slot) => runs.get(runKey(slot))?.run ?? null,
35661
+ isOwn: (pid) => descendsFrom(pid, process.pid),
35662
+ kill: (pid, signal) => {
35663
+ process.kill(pid, signal);
35664
+ },
35665
+ alive: (pid) => {
35666
+ try {
35667
+ process.kill(pid, 0);
35668
+ return true;
35669
+ } catch (e) {
35670
+ return e.code === "EPERM";
35671
+ }
35672
+ },
35673
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
35674
+ log: opts.log
35675
+ }).catch((e) => ({ ok: false, why: `end_threw: ${e.message}`.slice(0, 200) }));
35676
+ 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}`);
35677
+ await finishSessionEnd(end.endId, result, asHost).catch(() => {
35678
+ });
35679
+ }
35428
35680
  }
35429
35681
  async function presenceMap() {
35430
35682
  const presence = /* @__PURE__ */ new Map();
@@ -35449,6 +35701,14 @@ function startHost(opts) {
35449
35701
  const key = runKey(slot);
35450
35702
  if (slot === "Desktop" || isRevoked(slot)) return;
35451
35703
  const live = runs.get(key);
35704
+ if (live) {
35705
+ const theirs = (localHolderPids().get(slot) ?? []).map((p) => p.pid).filter((pid) => !descendsFrom(pid, process.pid));
35706
+ if (theirs.length) {
35707
+ opts.log(`session_stood_back ${JSON.stringify({ sessionId: slot, livePid: theirs[0] })} \u2014 a terminal runs this conversation; stopping this host's copy`);
35708
+ live.run.stop();
35709
+ return;
35710
+ }
35711
+ }
35452
35712
  const tokenId = slotIdentity(slot).tokenId;
35453
35713
  const seenByServer = tokenId ? presence.get(tokenId) : void 0;
35454
35714
  const holder = holders.get(slot);
@@ -35779,9 +36039,9 @@ async function pairThisMachine() {
35779
36039
  }
35780
36040
  }
35781
36041
  function forwardCodexThreadId() {
35782
- const cfg = join10(process.env.CODEX_HOME || join10(homedir10(), ".codex"), "config.toml");
36042
+ const cfg = join11(process.env.CODEX_HOME || join11(homedir10(), ".codex"), "config.toml");
35783
36043
  if (!existsSync5(cfg)) return;
35784
- const text = readFileSync6(cfg, "utf8");
36044
+ const text = readFileSync7(cfg, "utf8");
35785
36045
  const head = "[mcp_servers.paigy]\n";
35786
36046
  const at = text.indexOf(head);
35787
36047
  if (at < 0) return;
@@ -35790,7 +36050,8 @@ function forwardCodexThreadId() {
35790
36050
  return n < 0 ? text.length : n;
35791
36051
  })());
35792
36052
  if (/^\s*env_vars\s*=/m.test(block2)) return;
35793
- writeFileSync5(cfg, text.slice(0, at + head.length) + 'env_vars = ["CODEX_THREAD_ID", "CODEX_SESSION_ID"]\n' + text.slice(at + head.length));
36053
+ writeFileSync5(cfg, text.slice(0, at + head.length) + `env_vars = [${CODEX_ENV.map((name) => JSON.stringify(name)).join(", ")}]
36054
+ ` + text.slice(at + head.length));
35794
36055
  }
35795
36056
  async function main() {
35796
36057
  const parsed = parseArgs(process.argv.slice(2));
@@ -35808,6 +36069,7 @@ async function main() {
35808
36069
  console.log(" service install the host as a login service (macOS)");
35809
36070
  console.log(" enable-tools allowlist Paigy's tools in Claude Code so they don't prompt each time");
35810
36071
  console.log(" hatch NAME mint a sibling identity from this machine's credential");
36072
+ console.log(' launch NAME "brief" start a helper session with its own identity and its brief as its first Goal [--under GOAL]');
35811
36073
  console.log(" handoff pin this terminal's identity here so your phone can resume it");
35812
36074
  console.log(" triage sweep your whole note queue with a model on THIS machine and");
35813
36075
  console.log(" send grouped suggestions to your phone (close / stale / assign)");
@@ -35881,7 +36143,7 @@ async function main() {
35881
36143
  const wsDeps = { file: workspacesFile() };
35882
36144
  for (const dir of parsed.grant) addWorkspace(dir, wsDeps);
35883
36145
  if (!listWorkspaces(wsDeps).length) {
35884
- const guess = join10(homedir10(), "projects");
36146
+ const guess = join11(homedir10(), "projects");
35885
36147
  if (existsSync5(guess)) {
35886
36148
  addWorkspace(guess, wsDeps);
35887
36149
  console.log(`\u2713 workspace granted: ${guess} (change with --grant DIR)`);
@@ -35913,9 +36175,9 @@ async function main() {
35913
36175
  }
35914
36176
  }
35915
36177
  try {
35916
- const settings = join10(homedir10(), ".claude", "settings.json");
36178
+ const settings = join11(homedir10(), ".claude", "settings.json");
35917
36179
  if (which("claude") && existsSync5(settings)) {
35918
- const cfg = JSON.parse(readFileSync6(settings, "utf8"));
36180
+ const cfg = JSON.parse(readFileSync7(settings, "utf8"));
35919
36181
  if (!cfg["statusLine"]) {
35920
36182
  cfg["statusLine"] = { type: "command", command: "npx -y -p @paigy/mcp@latest paigy-statusline" };
35921
36183
  writeFileSync5(settings, JSON.stringify(cfg, null, 2));
@@ -35971,13 +36233,13 @@ async function main() {
35971
36233
  <key>SoftResourceLimits</key><dict>
35972
36234
  <key>NumberOfFiles</key><integer>65536</integer>
35973
36235
  </dict>
35974
- <key>StandardOutPath</key><string>${join10(homedir10(), ".paigy", "host.log")}</string>
35975
- <key>StandardErrorPath</key><string>${join10(homedir10(), ".paigy", "host.log")}</string>
36236
+ <key>StandardOutPath</key><string>${join11(homedir10(), ".paigy", "host.log")}</string>
36237
+ <key>StandardErrorPath</key><string>${join11(homedir10(), ".paigy", "host.log")}</string>
35976
36238
  </dict></plist>
35977
36239
  `;
35978
- const dir = join10(homedir10(), "Library", "LaunchAgents");
36240
+ const dir = join11(homedir10(), "Library", "LaunchAgents");
35979
36241
  mkdirSync4(dir, { recursive: true });
35980
- const path = join10(dir, `${SERVICE_LABEL}.plist`);
36242
+ const path = join11(dir, `${SERVICE_LABEL}.plist`);
35981
36243
  writeFileSync5(path, plist);
35982
36244
  execSync(`launchctl unload ${path} 2>/dev/null; launchctl load ${path}`, { shell: "/bin/sh" });
35983
36245
  console.log(`\u2713 host installed as a login service (${path}) \u2014 logs at ~/.paigy/host.log`);
@@ -36022,7 +36284,7 @@ async function main() {
36022
36284
  return;
36023
36285
  }
36024
36286
  if (parsed.hatch) {
36025
- overrideToken(readToken("Desktop") || null);
36287
+ overrideToken(readSlot("Desktop") || null);
36026
36288
  const minted = await hatch(parsed.hatch, parsed.voice ?? null);
36027
36289
  saveToken({ access_token: minted.token, name: minted.name ?? parsed.hatch, device: null }, parsed.slot ?? minted.name ?? parsed.hatch);
36028
36290
  if (!minted.name) {
@@ -36030,8 +36292,7 @@ async function main() {
36030
36292
  return;
36031
36293
  }
36032
36294
  console.log(`\u2713 hatched "${minted.name}" \u2014 it's on your phone's Agents screen now.`);
36033
- console.log(` speak as it: PAIGY_AGENT="${minted.name}" <any paigy tool>`);
36034
- console.log(` run with it: paigy-harness --identity "${minted.name}" --harness codex "\u2026"`);
36295
+ console.log(` start a helper with its own session instead: paigy-harness launch "<name>" "<brief>"`);
36035
36296
  return;
36036
36297
  }
36037
36298
  if (parsed.host) {
@@ -36049,21 +36310,27 @@ async function main() {
36049
36310
  });
36050
36311
  return;
36051
36312
  }
36052
- let identityToken;
36053
- if (parsed.identity) {
36054
- const slot = readToken(parsed.identity);
36055
- if (!slot) {
36056
- console.error(`No slot for "${parsed.identity}" \u2014 hatch it first: paigy-harness hatch "${parsed.identity}"`);
36057
- process.exit(1);
36058
- }
36059
- identityToken = slot;
36060
- }
36061
36313
  const state = detectAll().find((a) => a.name === parsed.harness);
36062
36314
  if (state && state.status !== "ready") {
36063
36315
  const hint = state.hint ?? "setup incomplete";
36064
36316
  console.error(`\u270B ${state.label}: ${hint}`);
36065
36317
  process.exit(1);
36066
36318
  }
36319
+ let launched;
36320
+ if (parsed.launch) {
36321
+ overrideToken(readSlot("Desktop") || null);
36322
+ const session = randomUUID7();
36323
+ const minted = await hatch(parsed.launch, parsed.voice ?? null, {
36324
+ reach: reachAs(session),
36325
+ brief: parsed.prompt,
36326
+ ...parsed.under ? { parentGoalId: parsed.under } : {}
36327
+ }).catch((e) => {
36328
+ console.error(`\u270B the helper was not launched: ${e.message}`);
36329
+ process.exit(1);
36330
+ }).finally(() => overrideToken(null));
36331
+ launched = { token: minted.token, session, ...minted.goalId ? { goalId: minted.goalId } : {} };
36332
+ console.log(`\u2713 launched "${minted.name ?? parsed.launch}"${minted.name ? "" : " (unnamed: you already have an agent with that name)"}, on its own session; its Goal is ${minted.goalId ?? "missing"}`);
36333
+ }
36067
36334
  const askTerminal = async (promptText) => {
36068
36335
  if (parsed.graceSeconds <= 0 || !process.stdin.isTTY) return null;
36069
36336
  const readline = await import("readline/promises");
@@ -36084,9 +36351,10 @@ async function main() {
36084
36351
  harness: parsed.harness,
36085
36352
  cwd: parsed.cwd,
36086
36353
  mode: parsed.mode,
36087
- prompt: parsed.prompt,
36354
+ // hatch already admitted this brief; the pump must not admit it again after completion.
36355
+ prompt: launched ? "" : parsed.prompt,
36088
36356
  ...state?.bin ? { bin: state.bin } : {},
36089
- ...identityToken ? { token: identityToken } : {},
36357
+ ...launched ? { token: launched.token, session: launched.session } : {},
36090
36358
  localAsk: {
36091
36359
  permission: async (summary) => {
36092
36360
  const said = await askTerminal(`\u23F8 blocked: ${summary} \u2014 allow? [y/n]`);
@@ -36098,7 +36366,7 @@ async function main() {
36098
36366
  });
36099
36367
  const beat2 = async () => {
36100
36368
  try {
36101
- await heartbeat();
36369
+ await heartbeat(void 0, launched ? { token: launched.token } : {});
36102
36370
  signedOut = false;
36103
36371
  } catch (e) {
36104
36372
  if (!isUnauthorized(e) || signedOut) return;