@paigy/harness 0.3.28 → 0.3.29

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.
package/dist/cli.js CHANGED
@@ -7585,8 +7585,8 @@ var require_webidl = __commonJS({
7585
7585
  return new TypeError(`${message.header}: ${message.message}`);
7586
7586
  };
7587
7587
  webidl.errors.conversionFailed = function(context) {
7588
- const plural2 = context.types.length === 1 ? "" : " one of";
7589
- const message = `${context.argument} could not be converted to${plural2}: ${context.types.join(", ")}.`;
7588
+ const plural3 = context.types.length === 1 ? "" : " one of";
7589
+ const message = `${context.argument} could not be converted to${plural3}: ${context.types.join(", ")}.`;
7590
7590
  return webidl.errors.exception({
7591
7591
  header: context.prefix,
7592
7592
  message
@@ -15050,9 +15050,9 @@ var require_pluralizer = __commonJS({
15050
15050
  this: "these"
15051
15051
  };
15052
15052
  module.exports = class Pluralizer {
15053
- constructor(singular, plural2) {
15053
+ constructor(singular, plural3) {
15054
15054
  this.singular = singular;
15055
- this.plural = plural2;
15055
+ this.plural = plural3;
15056
15056
  }
15057
15057
  pluralize(count) {
15058
15058
  const one = count === 1;
@@ -23743,7 +23743,7 @@ function updateSlot(agent2, patch) {
23743
23743
  }
23744
23744
  function slotIdentity(agent2) {
23745
23745
  const t = readTokenFile()[agent2];
23746
- return { name: t?.name ?? null, voice: t?.voice ?? null, tokenId: t?.token_id ?? null, workspace: t?.workspace ?? null };
23746
+ return { name: t?.name ?? null, voice: t?.voice ?? null, tokenId: t?.token_id ?? null, workspace: t?.workspace ?? null, harness: t?.harness ?? null, sessionId: t?.session_id ?? null };
23747
23747
  }
23748
23748
  function readToken(agent2 = agentName()) {
23749
23749
  if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
@@ -23783,9 +23783,14 @@ async function pollToken(deviceCode) {
23783
23783
  if (err === "access_denied") throw new Error("Access denied by user. Pairing cancelled.");
23784
23784
  throw new Error(`Unexpected error from /api/device/token: ${err}`);
23785
23785
  }
23786
- function ensureAuthed(res) {
23787
- if (res.status === 401) throw new UnpairedError();
23788
- return res;
23786
+ async function ensureAuthed(res) {
23787
+ if (res.status !== 401) return res;
23788
+ let body = null;
23789
+ try {
23790
+ body = await res.json();
23791
+ } catch {
23792
+ }
23793
+ throw body?.error === "revoked" ? new RevokedError() : new UnpairedError();
23789
23794
  }
23790
23795
  function overrideToken(secret) {
23791
23796
  tokenOverride = secret;
@@ -23795,7 +23800,7 @@ async function fail(what, res) {
23795
23800
  }
23796
23801
  async function createGoal(input, opts = {}) {
23797
23802
  const token = authToken(opts.token) ?? "";
23798
- const res = ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals`, {
23803
+ const res = await ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals`, {
23799
23804
  method: "POST",
23800
23805
  headers: { "content-type": "application/json", authorization: `Bearer ${token}`, "x-paigy-model": "goal-entry-v1" },
23801
23806
  body: JSON.stringify(input)
@@ -23805,13 +23810,14 @@ async function createGoal(input, opts = {}) {
23805
23810
  }
23806
23811
  async function claimGoal(goalId, opts = {}) {
23807
23812
  const token = authToken(opts.token) ?? "";
23808
- const res = ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals/claim`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}`, "x-paigy-model": "goal-entry-v1" }, body: JSON.stringify(goalId ? { goalId } : {}) }));
23813
+ const res = await ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals/claim`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}`, "x-paigy-model": "goal-entry-v1" }, body: JSON.stringify(goalId ? { goalId } : {}) }));
23809
23814
  if (!res.ok) await fail("claim_goal", res);
23810
23815
  return await res.json();
23811
23816
  }
23812
23817
  async function getGoal(goalId, opts = {}, read = {}) {
23813
23818
  const token = authToken(opts.token) ?? "";
23814
- const res = ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals/${encodeURIComponent(goalId)}${read.history ? "?history=1" : ""}`, {
23819
+ const query = [read.history ? "history=1" : "", read.diagnose ? "diagnose=1" : ""].filter(Boolean).join("&");
23820
+ const res = await ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals/${encodeURIComponent(goalId)}${query ? `?${query}` : ""}`, {
23815
23821
  method: "GET",
23816
23822
  headers: { authorization: `Bearer ${token}`, "x-paigy-model": "goal-entry-v1" }
23817
23823
  }));
@@ -23821,12 +23827,12 @@ async function getGoal(goalId, opts = {}, read = {}) {
23821
23827
  async function updateGoal(goalId, input, opts = {}) {
23822
23828
  const token = authToken(opts.token) ?? "";
23823
23829
  const operationId = input.operationId ?? randomUUID2();
23824
- const res = ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals/${encodeURIComponent(goalId)}`, { method: "PATCH", headers: { "content-type": "application/json", authorization: `Bearer ${token}`, "x-paigy-model": "goal-entry-v1" }, body: JSON.stringify({ ...input, operationId }) }));
23830
+ const res = await ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/goals/${encodeURIComponent(goalId)}`, { method: "PATCH", headers: { "content-type": "application/json", authorization: `Bearer ${token}`, "x-paigy-model": "goal-entry-v1" }, body: JSON.stringify({ ...input, operationId }) }));
23825
23831
  if (!res.ok) await fail("update_goal", res);
23826
23832
  return await res.json();
23827
23833
  }
23828
23834
  async function hatch(name, voice = null) {
23829
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/hatch`, {
23835
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/hatch`, {
23830
23836
  method: "POST",
23831
23837
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken()}` },
23832
23838
  body: JSON.stringify({ name, voice })
@@ -23845,19 +23851,27 @@ async function whoAmI(opts = {}) {
23845
23851
  }
23846
23852
  }
23847
23853
  async function claimSessions(opts = {}) {
23848
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/claim`, {
23854
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/claim`, {
23849
23855
  headers: { authorization: `Bearer ${authToken(opts.token)}` }
23850
23856
  // the HOST's identity
23851
23857
  }));
23852
23858
  if (!res.ok) throw new Error(`claim_sessions failed: ${res.status}`);
23853
23859
  return (await res.json()).sessions;
23854
23860
  }
23861
+ async function recordDecision(decision, opts = {}) {
23862
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/host/decisions`, {
23863
+ method: "POST",
23864
+ headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
23865
+ body: JSON.stringify(decision)
23866
+ }));
23867
+ if (!res.ok) await fail("record_decision", res);
23868
+ }
23855
23869
  async function heartbeat(runtime, opts = {}) {
23856
23870
  const body = {
23857
23871
  ...runtime !== void 0 ? { runtime } : {},
23858
23872
  ...opts.activity !== void 0 ? { activity: opts.activity } : {}
23859
23873
  };
23860
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/presence`, {
23874
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/presence`, {
23861
23875
  method: "POST",
23862
23876
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
23863
23877
  ...Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}
@@ -23865,7 +23879,7 @@ async function heartbeat(runtime, opts = {}) {
23865
23879
  if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
23866
23880
  }
23867
23881
  async function registerDelivery(mode, opts = {}) {
23868
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/delivery`, {
23882
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/delivery`, {
23869
23883
  method: "POST",
23870
23884
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
23871
23885
  body: JSON.stringify({ mode })
@@ -23874,21 +23888,21 @@ async function registerDelivery(mode, opts = {}) {
23874
23888
  return await res.json();
23875
23889
  }
23876
23890
  async function listNotes(opts = {}) {
23877
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notes`, {
23891
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/notes`, {
23878
23892
  headers: { authorization: `Bearer ${authToken(opts.token)}` }
23879
23893
  }));
23880
23894
  if (!res.ok) throw new Error(`list_notes failed: ${res.status} ${await res.text()}`);
23881
23895
  return await res.json();
23882
23896
  }
23883
23897
  async function listConnections(opts = {}) {
23884
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/tokens`, {
23898
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/tokens`, {
23885
23899
  headers: { authorization: `Bearer ${authToken(opts.token)}`, "x-paigy-model": "goal-entry-v1" }
23886
23900
  }));
23887
23901
  if (!res.ok) throw new Error(`list_connections failed: ${res.status} ${await res.text()}`);
23888
23902
  return await res.json();
23889
23903
  }
23890
23904
  async function submitTriage(run, opts = {}) {
23891
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/triage`, {
23905
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/triage`, {
23892
23906
  method: "POST",
23893
23907
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
23894
23908
  body: JSON.stringify(run)
@@ -23897,7 +23911,7 @@ async function submitTriage(run, opts = {}) {
23897
23911
  return await res.json();
23898
23912
  }
23899
23913
  async function acceptTriage(proposalId, body, opts = {}) {
23900
- const res = ensureAuthed(await reach(`${BACKEND_URL}/api/triage/${encodeURIComponent(proposalId)}/accept`, {
23914
+ const res = await ensureAuthed(await reach(`${BACKEND_URL}/api/triage/${encodeURIComponent(proposalId)}/accept`, {
23901
23915
  method: "POST",
23902
23916
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
23903
23917
  body: JSON.stringify(body)
@@ -23952,7 +23966,7 @@ async function contact(input, opts = {}) {
23952
23966
  ask: a.ask,
23953
23967
  options: a.options ?? []
23954
23968
  }));
23955
- const res = ensureAuthed(await send22(`${BACKEND_URL}/api/goals/contact`, {
23969
+ const res = await ensureAuthed(await send22(`${BACKEND_URL}/api/goals/contact`, {
23956
23970
  method: "POST",
23957
23971
  headers,
23958
23972
  signal: opts.signal,
@@ -23974,15 +23988,17 @@ async function contact(input, opts = {}) {
23974
23988
  }
23975
23989
  const all = deliveries.filter((d) => !!d.deliveryId && !!d.goalId).map(({ id, deliveryId: deliveryId2, goalId }) => ({ ...id ? { id } : {}, deliveryId: deliveryId2, goalId }));
23976
23990
  const joinedCard = deliveries[0]?.joinedCard === true;
23991
+ const joinedCall = deliveries[0]?.joinedCall === true;
23977
23992
  const read = async (signal2) => {
23978
- const res = ensureAuthed(await send22(`${BACKEND_URL}/api/deliveries/${encodeURIComponent(deliveryId)}`, { headers, signal: signal2 }));
23993
+ const res = await ensureAuthed(await send22(`${BACKEND_URL}/api/deliveries/${encodeURIComponent(deliveryId)}`, { headers, signal: signal2 }));
23979
23994
  if (!res.ok) await fail("read_delivery", res);
23980
23995
  const one = await res.json();
23981
23996
  return {
23982
23997
  ...one,
23983
23998
  ...all.length > 1 ? { deliveries: all } : {},
23984
23999
  ...recorded.length ? { recorded, notSent: NOT_SENT } : {},
23985
- ...joinedCard ? { joinedCard: true } : {}
24000
+ ...joinedCard ? { joinedCard: true } : {},
24001
+ ...joinedCall ? { joinedCall: true } : {}
23986
24002
  };
23987
24003
  };
23988
24004
  const settled = (d) => d.kind === "notification" || d.state === "closed" || d.answers.length > 0 || d.entries.some((e) => e.kind === "contribution");
@@ -24004,7 +24020,7 @@ async function contact(input, opts = {}) {
24004
24020
  }
24005
24021
  }
24006
24022
  async function checkReplies(opts = {}) {
24007
- const res = ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/deliveries`, {
24023
+ const res = await ensureAuthed(await (opts.reach ?? reach)(`${BACKEND_URL}/api/deliveries`, {
24008
24024
  headers: { authorization: `Bearer ${authToken(opts.token) ?? ""}`, "x-paigy-model": "goal-entry-v1" }
24009
24025
  }));
24010
24026
  if (!res.ok) await fail("check_replies", res);
@@ -24170,7 +24186,7 @@ async function subscribeWake(onNudge, opts = {}) {
24170
24186
  }
24171
24187
  };
24172
24188
  }
24173
- 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, 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, HistoryItemSchema, 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, WakeNudgeSchema, PairingStatusSchema, DeviceCodeSchema, DeviceInfoSchema, DeviceTokenSchema, SupportRequestSchema, NotificationFeedbackKindSchema, SlimOptionSchema, QuestionRowSchema, WorkStateSchema, WorkRowSchema, ComputerRowSchema, AgentRowSchema, SnapshotSchema, TOKEN_PATH, KEY_PATH, sleep, UnpairedError, ApiError, tokenOverride, authToken, AWAIT_WINDOW_MS, asked, NOT_SENT, HEARTBEAT_MS, RETRY_MS, JOIN, JOIN_TIMEOUT_MS, lines;
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, 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, 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;
24174
24190
  var init_dist = __esm({
24175
24191
  "../../packages/sdk/dist/index.js"() {
24176
24192
  "use strict";
@@ -24583,18 +24599,18 @@ var init_dist = __esm({
24583
24599
  parentId: external_exports.string().uuid().optional().describe("The Goal this question is about \u2014 usually the one you are working on. The question goes onto that Goal and its answer comes back there. Omit it and Paigy places the question in the tree itself."),
24584
24600
  repo: external_exports.string().optional().describe("Optional repository context."),
24585
24601
  ask: external_exports.string().trim().min(1).max(1e4).describe(
24586
- "ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 parts of one piece of work under one Goal, separate things as separate Goals \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; a call contact JOINS a call already happening, so several arrive as one call."
24602
+ "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."
24587
24603
  ),
24588
- options: external_exports.array(OptionInputSchema).min(2).max(6).optional()
24604
+ options: external_exports.array(OptionInputSchema).min(1).max(6).optional()
24589
24605
  }).strict();
24590
24606
  StartContactSchema = external_exports.object({
24591
24607
  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."),
24592
- waiting: external_exports.enum(["none", "hard"]).default("none"),
24608
+ waiting: external_exports.enum(["none", "hard"]).default("none").describe("hard requests a call even when channel is notification; user permissions and ring cooldowns still apply."),
24593
24609
  channel: external_exports.enum(["notification", "call"]).default("notification")
24594
24610
  }).strict();
24595
24611
  ContactSchema = external_exports.union([StartContactSchema, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
24596
24612
  CONTACT_SCHEMA = { type: "object", ...mcpInputSchema(ContactSchema) };
24597
- CONTACT_DESCRIPTION = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read \u2014 parts of one piece of work under one Goal, separate things as separate Goals. An ask with no options that is not blocking is a report: on a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so).";
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.";
24598
24614
  CreateGoalSchema = external_exports.object({
24599
24615
  outcome: external_exports.string().trim().min(1).max(1e4),
24600
24616
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -24632,7 +24648,11 @@ var init_dist = __esm({
24632
24648
  state: external_exports.enum(["active", "done", "cancelled"]).optional(),
24633
24649
  progress: external_exports.string().trim().min(1).max(1e4).optional(),
24634
24650
  reviewed: external_exports.literal(true).optional(),
24635
- dueAt: external_exports.string().datetime({ offset: true }).nullable().optional()
24651
+ dueAt: external_exports.string().datetime({ offset: true }).nullable().optional(),
24652
+ /** WITHDRAW YOUR OWN QUESTION (#2777, owner 2026-09-30). The question's id as every read shows it
24653
+ * (the conversation's `id`, eight characters, or the whole request Entry id), on THIS Goal, asked
24654
+ * by you and still open. It is cancelled, not answered: its cards close and nothing rings for it. */
24655
+ withdraw: external_exports.array(external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/)).min(1).max(10).optional()
24636
24656
  }).strict().refine((v) => Object.keys(v).length > 0),
24637
24657
  reason: external_exports.string().trim().min(1).max(2e3),
24638
24658
  operationId: external_exports.string().uuid().optional()
@@ -24643,10 +24663,14 @@ var init_dist = __esm({
24643
24663
  goalId: external_exports.string().uuid(),
24644
24664
  /** Every entry in full. Without it the read carries the person's words, open questions and your
24645
24665
  * newest entry, and counts what it left out (`apps/api/src/goal/collapse.ts`). */
24646
- history: external_exports.boolean().optional()
24666
+ history: external_exports.boolean().optional(),
24667
+ /** WHEN A READ HAS CONFUSED YOU. Adds `diagnosis`: every reader that already answers a question
24668
+ * about this work, each answer attributed to the reader that gave it, and every disagreement
24669
+ * between two of them named. Off by default; `apps/api/src/goal/diagnose-design.md`. */
24670
+ diagnose: external_exports.boolean().optional()
24647
24671
  }).strict();
24648
- 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.";
24649
- UPDATE_GOAL_DESCRIPTION = `Update an owned Goal at an exact revision. State, ownership, dependencies, children, progress, title, and review acknowledgement are explicit; stale revisions are rejected. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
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.`;
24650
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.";
24651
24675
  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.";
24652
24676
  CheckRepliesSchema = external_exports.object({}).strict();
@@ -25250,18 +25274,20 @@ var init_dist = __esm({
25250
25274
  * clobber guard as voiceMode: absent = leave unchanged on write. */
25251
25275
  broker: BrokerTuningSchema.optional()
25252
25276
  });
25253
- HistoryItemSchema = external_exports.object({
25277
+ HistoryWorkSchema = external_exports.object({
25254
25278
  id: external_exports.string(),
25255
- /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
25256
- initiator: external_exports.enum(["user", "agent"]),
25257
25279
  title: external_exports.string(),
25258
- /** The agent on the other end (its name). */
25259
- name: external_exports.string(),
25260
- createdAt: external_exports.string(),
25261
- /** When the agent fetched your request (user→agent only). */
25262
- agentAckedAt: external_exports.string().nullable(),
25263
- /** When you answered the agent's notification (agent→user only). */
25264
- humanAckedAt: external_exports.string().nullable()
25280
+ state: external_exports.enum(["done", "cancelled"]),
25281
+ /** Who held it (`agent:<tokenId>` or `human:<userId>`). */
25282
+ assignee: external_exports.string()
25283
+ });
25284
+ HistoryEntrySchema = external_exports.union([
25285
+ external_exports.object({ at: external_exports.string(), card: InboxItemSchema }),
25286
+ external_exports.object({ at: external_exports.string(), work: HistoryWorkSchema })
25287
+ ]);
25288
+ HistoryPageSchema = external_exports.object({
25289
+ entries: external_exports.array(HistoryEntrySchema),
25290
+ next: external_exports.string().nullable()
25265
25291
  });
25266
25292
  ACTIVITY_LINES = 2;
25267
25293
  ACTIVITY_LINE_MAX = 80;
@@ -25445,6 +25471,9 @@ var init_dist = __esm({
25445
25471
  progressLine: external_exports.string().nullable().optional(),
25446
25472
  reviewPending: external_exports.boolean().default(false),
25447
25473
  dueAt: external_exports.string().nullable().default(null),
25474
+ /** WHEN ITS OWNER SAID DONE WHILE CHILDREN WERE OPEN (#2704): its own work is finished and it closes
25475
+ * with its last open child. Null otherwise; optional, so hand-built queues need not spell it. */
25476
+ finishedAt: external_exports.string().nullable().optional(),
25448
25477
  /** The Goal this one was opened under; null at the root. */
25449
25478
  parentGoalId: external_exports.string().nullable().default(null),
25450
25479
  /** Goals opened under this one — only those the same list holds. */
@@ -25453,8 +25482,17 @@ var init_dist = __esm({
25453
25482
  dependencyGoalIds: external_exports.array(external_exports.string()).default([]),
25454
25483
  /** True while any gate is on a Goal that is not done — the walk draws it dashed. */
25455
25484
  blocked: external_exports.boolean().default(false),
25456
- /** Every decision need on it, open or settled — the page decides which to show. */
25485
+ /** Its questions: every OPEN one, and at most ten settled, newest settled first
25486
+ * (20260929133308) — the page decides which of them to show. NOT the whole set: `asked` and
25487
+ * `answered` are, and a settled one's words are a line (280 characters), its body read when the
25488
+ * question is opened. */
25457
25489
  questions: external_exports.array(QueueQuestionSchema).default([]),
25490
+ /** HOW MANY QUESTIONS THIS WORK HAS ASKED, and how many are answered — the Goal's own totals,
25491
+ * bounded at 100 server-side. A tally counted off `questions` is a wrong number that looks
25492
+ * right once the cap bites (`walk/trees.ts` `tallyOf`). Optional, and defaulted from the array
25493
+ * by the projection, so hand-built queues (fixtures, the demo) need not spell them. */
25494
+ asked: external_exports.number().optional(),
25495
+ answered: external_exports.number().optional(),
25458
25496
  /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
25459
25497
  * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
25460
25498
  * not spell an empty list. */
@@ -25591,6 +25629,19 @@ var init_dist = __esm({
25591
25629
  * carries credentials; only a `poll` registration can come back with null here. */
25592
25630
  realtime: external_exports.object({ url: external_exports.string(), anonKey: external_exports.string() }).nullable()
25593
25631
  });
25632
+ HostDecisionSchema = external_exports.object({
25633
+ /** The agent's token id: the row's `recipient`. */
25634
+ agent: external_exports.string().uuid(),
25635
+ decision: external_exports.enum(["stood_back", "took_over"]),
25636
+ /** The work it was about: the Goal `claim_goal` would hand that agent next. */
25637
+ goalId: external_exports.string().uuid().nullable().optional(),
25638
+ /** When the server last heard from the agent, as the host read it: the presence it stood back for. */
25639
+ seenAt: external_exports.string().datetime().nullable().optional(),
25640
+ /** When that work last moved (`claimable.since` on `check_replies`), the fact the bound is judged on. */
25641
+ since: external_exports.string().datetime().nullable().optional(),
25642
+ /** What the host said, in its log's own words: why it stood back, or what the take-over did. */
25643
+ said: external_exports.string().max(300).optional()
25644
+ });
25594
25645
  WakeNudgeSchema = external_exports.object({
25595
25646
  kind: external_exports.enum(["reply", "request", "callback"]),
25596
25647
  notificationId: external_exports.string().optional(),
@@ -25635,6 +25686,9 @@ var init_dist = __esm({
25635
25686
  * landed in the FIRST granted workspace and the agent rediscovered its own repo from
25636
25687
  * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
25637
25688
  workspace: external_exports.string().nullable().optional(),
25689
+ /** Local host recovery must preserve the launch's runtime and Paigy identity. */
25690
+ harness: external_exports.enum(["claude", "codex", "agy"]).optional(),
25691
+ session_id: external_exports.string().uuid().optional(),
25638
25692
  uik_pub: external_exports.string().nullable().optional()
25639
25693
  });
25640
25694
  SupportRequestSchema = external_exports.object({
@@ -25763,6 +25817,13 @@ var init_dist = __esm({
25763
25817
  this.name = "UnpairedError";
25764
25818
  }
25765
25819
  };
25820
+ RevokedError = class extends UnpairedError {
25821
+ constructor() {
25822
+ super();
25823
+ this.message = "This identity was revoked (the API answered 401 revoked): its credential is gone for good. Pairing again makes a new identity.";
25824
+ this.name = "RevokedError";
25825
+ }
25826
+ };
25766
25827
  ApiError = class extends Error {
25767
25828
  status;
25768
25829
  body;
@@ -30358,132 +30419,6 @@ var require_lib = __commonJS({
30358
30419
  }
30359
30420
  });
30360
30421
 
30361
- // src/triage/judge.ts
30362
- import { spawnSync as spawnSync2 } from "child_process";
30363
- import { homedir as homedir9 } from "os";
30364
- import { delimiter as delimiter3 } from "path";
30365
- function pickOllamaModel(tags) {
30366
- if (tags.length === 0) return null;
30367
- const score = (tag) => {
30368
- const i = PREFERRED.findIndex((p) => tag.toLowerCase().startsWith(p));
30369
- return i === -1 ? PREFERRED.length : i;
30370
- };
30371
- return [...tags].sort((a, b) => score(a) - score(b) || a.length - b.length)[0] ?? null;
30372
- }
30373
- function parseOllamaTags(body) {
30374
- const models = body?.models;
30375
- if (!Array.isArray(models)) return [];
30376
- return models.map((m) => m?.name ?? m?.model).filter((n) => typeof n === "string" && n.length > 0);
30377
- }
30378
- async function fetchTags(host) {
30379
- try {
30380
- const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(2e3) });
30381
- if (!res.ok) return null;
30382
- return parseOllamaTags(await res.json());
30383
- } catch {
30384
- return null;
30385
- }
30386
- }
30387
- async function available(deps = {}) {
30388
- const tags = await (deps.tags ?? fetchTags)(OLLAMA_HOST);
30389
- const model = tags ? pickOllamaModel(tags) : null;
30390
- const ollama = tags === null ? { provider: "ollama", ready: false, model: null, hint: `no ollama at ${OLLAMA_HOST} \u2014 install it: https://ollama.com/download` } : model === null ? { provider: "ollama", ready: false, model: null, hint: "ollama is running but has no models \u2014 pull one: ollama pull llama3.2:3b" } : { provider: "ollama", ready: true, model };
30391
- const detected = (deps.detect ?? detectAll)();
30392
- const harnesses = CATALOG.map((entry) => {
30393
- const state = detected.find((d) => d.name === entry.name);
30394
- const usable = state?.status === "ready" || state?.status === "adapter-missing";
30395
- return usable ? { provider: entry.name, ready: true, model: null } : { provider: entry.name, ready: false, model: null, hint: state?.hint ?? `${entry.label} is not installed.` };
30396
- });
30397
- return [ollama, ...harnesses];
30398
- }
30399
- function refusal(options, asked2) {
30400
- const lines2 = [
30401
- asked2 ? `Can't judge with --via ${asked2}: ${options.find((o) => o.provider === asked2)?.hint ?? "it isn't available on this machine"}` : "No local judge available \u2014 triage refuses rather than sending your queue to a cloud model.",
30402
- "",
30403
- "Triage runs the model where your queue already lives (#1106). Your options:"
30404
- ];
30405
- for (const o of options) {
30406
- lines2.push(` ${o.ready ? "\u2713" : "\u2717"} ${o.provider.padEnd(7)} ${PRIVACY[o.provider]}`);
30407
- if (!o.ready && o.hint) lines2.push(` ${o.hint}`);
30408
- }
30409
- lines2.push("", "Then re-run: paigy-harness triage [--via ollama|claude|codex|agy]");
30410
- return lines2.join("\n");
30411
- }
30412
- async function judgeFor(asked2, deps = {}) {
30413
- const options = await available(deps);
30414
- const chosen = asked2 ? options.find((o) => o.provider === asked2) : options.find((o) => o.ready);
30415
- if (!chosen?.ready) return { error: refusal(options, asked2) };
30416
- if (chosen.provider === "ollama") {
30417
- const model = chosen.model;
30418
- return {
30419
- provider: "ollama",
30420
- model,
30421
- run: async (prompt) => {
30422
- const res = await fetch(`${OLLAMA_HOST}/api/generate`, {
30423
- method: "POST",
30424
- headers: { "content-type": "application/json" },
30425
- // Non-streaming: we want the whole answer once, and the parser is the same
30426
- // either way. `temperature: 0` because this is a classification, not prose.
30427
- body: JSON.stringify({ model, prompt, stream: false, options: { temperature: 0 } }),
30428
- signal: AbortSignal.timeout(RUN_TIMEOUT_MS)
30429
- });
30430
- if (!res.ok) throw new Error(`ollama ${res.status}: ${await res.text()}`);
30431
- return (await res.json()).response ?? "";
30432
- }
30433
- };
30434
- }
30435
- const runner = RUNNERS[chosen.provider];
30436
- if (!runner) return { error: refusal(options, asked2) };
30437
- const bin = which(runner.cli);
30438
- if (!bin) return { error: refusal(options, asked2) };
30439
- return {
30440
- provider: chosen.provider,
30441
- model: null,
30442
- run: async (prompt) => {
30443
- const out = spawnSync2(bin, runner.argv(null), {
30444
- input: prompt,
30445
- encoding: "utf8",
30446
- cwd: homedir9(),
30447
- timeout: RUN_TIMEOUT_MS,
30448
- maxBuffer: 32 * 1024 * 1024,
30449
- env: { ...process.env, PATH: [process.env["PATH"], ...probeDirs()].filter(Boolean).join(delimiter3) }
30450
- });
30451
- if (out.error) throw new Error(`${runner.cli} failed to run: ${out.error.message}`);
30452
- if (out.status !== 0 && !out.stdout?.trim())
30453
- throw new Error(`${runner.cli} exited ${out.status ?? "?"}: ${(out.stderr ?? "").trim().slice(0, 500)}`);
30454
- return out.stdout ?? "";
30455
- }
30456
- };
30457
- }
30458
- var PRIVACY, OLLAMA_HOST, PREFERRED, RUNNERS, RUN_TIMEOUT_MS;
30459
- var init_judge = __esm({
30460
- "src/triage/judge.ts"() {
30461
- "use strict";
30462
- init_catalog();
30463
- PRIVACY = {
30464
- ollama: "never leaves this machine",
30465
- claude: "this machine + your own Claude subscription; Paigy's servers never see it",
30466
- codex: "this machine + your own Codex subscription; Paigy's servers never see it",
30467
- agy: "this machine + your own Antigravity account; Paigy's servers never see it"
30468
- };
30469
- OLLAMA_HOST = process.env["OLLAMA_HOST"] ?? "http://localhost:11434";
30470
- PREFERRED = ["llama3.2", "qwen2.5", "phi", "gemma", "mistral", "llama3.1", "llama3"];
30471
- RUNNERS = {
30472
- // Ollama speaks HTTP, not a CLI — handled separately in `judgeFor`.
30473
- ollama: null,
30474
- // `claude -p` is print mode: one prompt, one answer, no session. Verified 2026-08-28.
30475
- claude: { cli: "claude", argv: () => ["-p"] },
30476
- // `codex exec` is Codex's documented non-interactive run; it reads the prompt from stdin
30477
- // when none is given as an argument. It prints its own preamble around the answer, which
30478
- // is exactly why the parser hunts for the JSON rather than trusting the whole of stdout.
30479
- codex: { cli: "codex", argv: () => ["exec", "--skip-git-repo-check", "-"] },
30480
- // `agy --print` (alias -p) is Antigravity's print mode, text output by default.
30481
- agy: { cli: "agy", argv: () => ["-p"] }
30482
- };
30483
- RUN_TIMEOUT_MS = 5 * 6e4;
30484
- }
30485
- });
30486
-
30487
30422
  // src/triage/evidence.ts
30488
30423
  function assigneeToken(note2) {
30489
30424
  const ref = note2.assignee ?? "";
@@ -30659,6 +30594,132 @@ var init_evidence = __esm({
30659
30594
  }
30660
30595
  });
30661
30596
 
30597
+ // src/triage/judge.ts
30598
+ import { spawnSync as spawnSync2 } from "child_process";
30599
+ import { homedir as homedir5 } from "os";
30600
+ import { delimiter as delimiter3 } from "path";
30601
+ function pickOllamaModel(tags) {
30602
+ if (tags.length === 0) return null;
30603
+ const score = (tag) => {
30604
+ const i = PREFERRED.findIndex((p) => tag.toLowerCase().startsWith(p));
30605
+ return i === -1 ? PREFERRED.length : i;
30606
+ };
30607
+ return [...tags].sort((a, b) => score(a) - score(b) || a.length - b.length)[0] ?? null;
30608
+ }
30609
+ function parseOllamaTags(body) {
30610
+ const models = body?.models;
30611
+ if (!Array.isArray(models)) return [];
30612
+ return models.map((m) => m?.name ?? m?.model).filter((n) => typeof n === "string" && n.length > 0);
30613
+ }
30614
+ async function fetchTags(host) {
30615
+ try {
30616
+ const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(2e3) });
30617
+ if (!res.ok) return null;
30618
+ return parseOllamaTags(await res.json());
30619
+ } catch {
30620
+ return null;
30621
+ }
30622
+ }
30623
+ async function available(deps = {}) {
30624
+ const tags = await (deps.tags ?? fetchTags)(OLLAMA_HOST);
30625
+ const model = tags ? pickOllamaModel(tags) : null;
30626
+ const ollama = tags === null ? { provider: "ollama", ready: false, model: null, hint: `no ollama at ${OLLAMA_HOST} \u2014 install it: https://ollama.com/download` } : model === null ? { provider: "ollama", ready: false, model: null, hint: "ollama is running but has no models \u2014 pull one: ollama pull llama3.2:3b" } : { provider: "ollama", ready: true, model };
30627
+ const detected = (deps.detect ?? detectAll)();
30628
+ const harnesses = CATALOG.map((entry) => {
30629
+ const state = detected.find((d) => d.name === entry.name);
30630
+ const usable = state?.status === "ready" || state?.status === "adapter-missing";
30631
+ return usable ? { provider: entry.name, ready: true, model: null } : { provider: entry.name, ready: false, model: null, hint: state?.hint ?? `${entry.label} is not installed.` };
30632
+ });
30633
+ return [ollama, ...harnesses];
30634
+ }
30635
+ function refusal(options, asked2) {
30636
+ const lines2 = [
30637
+ asked2 ? `Can't judge with --via ${asked2}: ${options.find((o) => o.provider === asked2)?.hint ?? "it isn't available on this machine"}` : "No local judge available \u2014 triage refuses rather than sending your queue to a cloud model.",
30638
+ "",
30639
+ "Triage runs the model where your queue already lives (#1106). Your options:"
30640
+ ];
30641
+ for (const o of options) {
30642
+ lines2.push(` ${o.ready ? "\u2713" : "\u2717"} ${o.provider.padEnd(7)} ${PRIVACY[o.provider]}`);
30643
+ if (!o.ready && o.hint) lines2.push(` ${o.hint}`);
30644
+ }
30645
+ lines2.push("", "Then re-run: paigy-harness triage [--via ollama|claude|codex|agy]");
30646
+ return lines2.join("\n");
30647
+ }
30648
+ async function judgeFor(asked2, deps = {}) {
30649
+ const options = await available(deps);
30650
+ const chosen = asked2 ? options.find((o) => o.provider === asked2) : options.find((o) => o.ready);
30651
+ if (!chosen?.ready) return { error: refusal(options, asked2) };
30652
+ if (chosen.provider === "ollama") {
30653
+ const model = chosen.model;
30654
+ return {
30655
+ provider: "ollama",
30656
+ model,
30657
+ run: async (prompt) => {
30658
+ const res = await fetch(`${OLLAMA_HOST}/api/generate`, {
30659
+ method: "POST",
30660
+ headers: { "content-type": "application/json" },
30661
+ // Non-streaming: we want the whole answer once, and the parser is the same
30662
+ // either way. `temperature: 0` because this is a classification, not prose.
30663
+ body: JSON.stringify({ model, prompt, stream: false, options: { temperature: 0 } }),
30664
+ signal: AbortSignal.timeout(RUN_TIMEOUT_MS)
30665
+ });
30666
+ if (!res.ok) throw new Error(`ollama ${res.status}: ${await res.text()}`);
30667
+ return (await res.json()).response ?? "";
30668
+ }
30669
+ };
30670
+ }
30671
+ const runner = RUNNERS[chosen.provider];
30672
+ if (!runner) return { error: refusal(options, asked2) };
30673
+ const bin = which(runner.cli);
30674
+ if (!bin) return { error: refusal(options, asked2) };
30675
+ return {
30676
+ provider: chosen.provider,
30677
+ model: null,
30678
+ run: async (prompt) => {
30679
+ const out = spawnSync2(bin, runner.argv(null), {
30680
+ input: prompt,
30681
+ encoding: "utf8",
30682
+ cwd: homedir5(),
30683
+ timeout: RUN_TIMEOUT_MS,
30684
+ maxBuffer: 32 * 1024 * 1024,
30685
+ env: { ...process.env, PATH: [process.env["PATH"], ...probeDirs()].filter(Boolean).join(delimiter3) }
30686
+ });
30687
+ if (out.error) throw new Error(`${runner.cli} failed to run: ${out.error.message}`);
30688
+ if (out.status !== 0 && !out.stdout?.trim())
30689
+ throw new Error(`${runner.cli} exited ${out.status ?? "?"}: ${(out.stderr ?? "").trim().slice(0, 500)}`);
30690
+ return out.stdout ?? "";
30691
+ }
30692
+ };
30693
+ }
30694
+ var PRIVACY, OLLAMA_HOST, PREFERRED, RUNNERS, RUN_TIMEOUT_MS;
30695
+ var init_judge = __esm({
30696
+ "src/triage/judge.ts"() {
30697
+ "use strict";
30698
+ init_catalog();
30699
+ PRIVACY = {
30700
+ ollama: "never leaves this machine",
30701
+ claude: "this machine + your own Claude subscription; Paigy's servers never see it",
30702
+ codex: "this machine + your own Codex subscription; Paigy's servers never see it",
30703
+ agy: "this machine + your own Antigravity account; Paigy's servers never see it"
30704
+ };
30705
+ OLLAMA_HOST = process.env["OLLAMA_HOST"] ?? "http://localhost:11434";
30706
+ PREFERRED = ["llama3.2", "qwen2.5", "phi", "gemma", "mistral", "llama3.1", "llama3"];
30707
+ RUNNERS = {
30708
+ // Ollama speaks HTTP, not a CLI — handled separately in `judgeFor`.
30709
+ ollama: null,
30710
+ // `claude -p` is print mode: one prompt, one answer, no session. Verified 2026-08-28.
30711
+ claude: { cli: "claude", argv: () => ["-p"] },
30712
+ // `codex exec` is Codex's documented non-interactive run; it reads the prompt from stdin
30713
+ // when none is given as an argument. It prints its own preamble around the answer, which
30714
+ // is exactly why the parser hunts for the JSON rather than trusting the whole of stdout.
30715
+ codex: { cli: "codex", argv: () => ["exec", "--skip-git-repo-check", "-"] },
30716
+ // `agy --print` (alias -p) is Antigravity's print mode, text output by default.
30717
+ agy: { cli: "agy", argv: () => ["-p"] }
30718
+ };
30719
+ RUN_TIMEOUT_MS = 5 * 6e4;
30720
+ }
30721
+ });
30722
+
30662
30723
  // src/triage/verdict.ts
30663
30724
  function extractJson(raw) {
30664
30725
  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
@@ -32861,18 +32922,18 @@ var AskInputSchema2 = external_exports.object({
32861
32922
  parentId: external_exports.string().uuid().optional().describe("The Goal this question is about \u2014 usually the one you are working on. The question goes onto that Goal and its answer comes back there. Omit it and Paigy places the question in the tree itself."),
32862
32923
  repo: external_exports.string().optional().describe("Optional repository context."),
32863
32924
  ask: external_exports.string().trim().min(1).max(1e4).describe(
32864
- "ONE question, and only what is needed to answer it. Several questions are several asks in the array, one each: an answer settles the one ask it was given in the shape that ask declares, so a person who answers the part of a bundled ask that interested them settles nothing and is asked again. Paigy reads every ask once and takes a bundled one apart into its own cards anyway \u2014 parts of one piece of work under one Goal, separate things as separate Goals \u2014 but the words it splits are its reading, not yours. News, progress and findings are their own contact; a call contact JOINS a call already happening, so several arrive as one call."
32925
+ "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."
32865
32926
  ),
32866
- options: external_exports.array(OptionInputSchema2).min(2).max(6).optional()
32927
+ options: external_exports.array(OptionInputSchema2).min(1).max(6).optional()
32867
32928
  }).strict();
32868
32929
  var StartContactSchema2 = external_exports.object({
32869
32930
  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."),
32870
- waiting: external_exports.enum(["none", "hard"]).default("none"),
32931
+ waiting: external_exports.enum(["none", "hard"]).default("none").describe("hard requests a call even when channel is notification; user permissions and ring cooldowns still apply."),
32871
32932
  channel: external_exports.enum(["notification", "call"]).default("notification")
32872
32933
  }).strict();
32873
32934
  var ContactSchema2 = external_exports.union([StartContactSchema2, external_exports.object({ deliveryId: external_exports.string().uuid() }).strict()]);
32874
32935
  var CONTACT_SCHEMA2 = { type: "object", ...mcpInputSchema2(ContactSchema2) };
32875
- var CONTACT_DESCRIPTION2 = "Contact the user with one or more asks/questions. Pass an array of asks (each with 'ask', optional 'options', 'id', 'parentId', 'repo'), plus channel:'notification'|'call', and waiting:'none'|'hard'. An ask that names a Goal (parentId) goes onto that Goal; one that names none is placed in the person's tree by Paigy. Notification returns immediately; collect durable answers with check_replies. On stdio, a Call holds one cancellable ~45s window; continue with ONLY {deliveryId}. One question per 'ask'; several questions are several objects in 'asks'. A bundled ask is taken apart into its own cards by Paigy's one intake read \u2014 parts of one piece of work under one Goal, separate things as separate Goals. An ask with no options that is not blocking is a report: on a Goal whose report card is still open it is added to that card, with no new push; one that only says where your work stands while under way is recorded as the Goal's progress, not sent (the result says so).";
32936
+ 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.";
32876
32937
  var CreateGoalSchema2 = external_exports.object({
32877
32938
  outcome: external_exports.string().trim().min(1).max(1e4),
32878
32939
  /** The work's NAME (#2115) — one to five words, how a person refers to it out loud ("the night
@@ -32910,7 +32971,11 @@ var UpdateGoalSchema2 = external_exports.object({
32910
32971
  state: external_exports.enum(["active", "done", "cancelled"]).optional(),
32911
32972
  progress: external_exports.string().trim().min(1).max(1e4).optional(),
32912
32973
  reviewed: external_exports.literal(true).optional(),
32913
- dueAt: external_exports.string().datetime({ offset: true }).nullable().optional()
32974
+ dueAt: external_exports.string().datetime({ offset: true }).nullable().optional(),
32975
+ /** WITHDRAW YOUR OWN QUESTION (#2777, owner 2026-09-30). The question's id as every read shows it
32976
+ * (the conversation's `id`, eight characters, or the whole request Entry id), on THIS Goal, asked
32977
+ * by you and still open. It is cancelled, not answered: its cards close and nothing rings for it. */
32978
+ withdraw: external_exports.array(external_exports.string().trim().regex(/^[0-9a-fA-F-]{8,36}$/)).min(1).max(10).optional()
32914
32979
  }).strict().refine((v) => Object.keys(v).length > 0),
32915
32980
  reason: external_exports.string().trim().min(1).max(2e3),
32916
32981
  operationId: external_exports.string().uuid().optional()
@@ -32921,10 +32986,14 @@ var GetGoalSchema2 = external_exports.object({
32921
32986
  goalId: external_exports.string().uuid(),
32922
32987
  /** Every entry in full. Without it the read carries the person's words, open questions and your
32923
32988
  * newest entry, and counts what it left out (`apps/api/src/goal/collapse.ts`). */
32924
- history: external_exports.boolean().optional()
32989
+ history: external_exports.boolean().optional(),
32990
+ /** WHEN A READ HAS CONFUSED YOU. Adds `diagnosis`: every reader that already answers a question
32991
+ * about this work, each answer attributed to the reader that gave it, and every disagreement
32992
+ * between two of them named. Off by default; `apps/api/src/goal/diagnose-design.md`. */
32993
+ diagnose: external_exports.boolean().optional()
32925
32994
  }).strict();
32926
- 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.";
32927
- var UPDATE_GOAL_DESCRIPTION2 = `Update an owned Goal at an exact revision. State, ownership, dependencies, children, progress, title, and review acknowledgement are explicit; stale revisions are rejected. title is the work's name in one to five words, as a person would refer to it out loud (it is spoken on a call and heads every list); null clears it. reviewed: true acknowledges new evidence and closes the Deliveries addressed to you on that Goal, never over an open decision. dueAt (an ISO instant, or null) makes the Goal wait until then; when it passes you are woken for it \u2014 use it for a promise to follow up later. state: done means the work was accomplished, and it can be reopened: when the person says it is not done ("it's not working"), set state: active on that same Goal instead of starting a new one; its parent reopens with it. cancelled is final. Returns the Goal as get_goal reads it, at its new revision.`;
32995
+ 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.";
32996
+ 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.`;
32928
32997
  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.";
32929
32998
  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.";
32930
32999
  var CheckRepliesSchema2 = external_exports.object({}).strict();
@@ -32937,7 +33006,6 @@ var AGENT_TOOLS2 = [
32937
33006
  { name: "update_goal", description: UPDATE_GOAL_DESCRIPTION2, inputSchema: mcpInputSchema2(UpdateGoalToolSchema2) }
32938
33007
  ];
32939
33008
  var AGENT_TOOL_NAMES2 = AGENT_TOOLS2.map((t) => t.name);
32940
- var DONE_MESSAGE = "When the work is done, send one message through contact saying what is done and anything the person needs to do or check.";
32941
33009
  function entryWords(entry) {
32942
33010
  const content = entry.content;
32943
33011
  if (content && "sealed" in content) return "";
@@ -33544,18 +33612,20 @@ var UserSettingsSchema2 = external_exports.object({
33544
33612
  * clobber guard as voiceMode: absent = leave unchanged on write. */
33545
33613
  broker: BrokerTuningSchema2.optional()
33546
33614
  });
33547
- var HistoryItemSchema2 = external_exports.object({
33615
+ var HistoryWorkSchema2 = external_exports.object({
33548
33616
  id: external_exports.string(),
33549
- /** 'user' = a request you sent; 'agent' = a notification an agent sent you. */
33550
- initiator: external_exports.enum(["user", "agent"]),
33551
33617
  title: external_exports.string(),
33552
- /** The agent on the other end (its name). */
33553
- name: external_exports.string(),
33554
- createdAt: external_exports.string(),
33555
- /** When the agent fetched your request (user→agent only). */
33556
- agentAckedAt: external_exports.string().nullable(),
33557
- /** When you answered the agent's notification (agent→user only). */
33558
- humanAckedAt: external_exports.string().nullable()
33618
+ state: external_exports.enum(["done", "cancelled"]),
33619
+ /** Who held it (`agent:<tokenId>` or `human:<userId>`). */
33620
+ assignee: external_exports.string()
33621
+ });
33622
+ var HistoryEntrySchema2 = external_exports.union([
33623
+ external_exports.object({ at: external_exports.string(), card: InboxItemSchema2 }),
33624
+ external_exports.object({ at: external_exports.string(), work: HistoryWorkSchema2 })
33625
+ ]);
33626
+ var HistoryPageSchema2 = external_exports.object({
33627
+ entries: external_exports.array(HistoryEntrySchema2),
33628
+ next: external_exports.string().nullable()
33559
33629
  });
33560
33630
  var ACTIVITY_LINES2 = 2;
33561
33631
  var ACTIVITY_LINE_MAX2 = 80;
@@ -33739,6 +33809,9 @@ var QueueItemSchema2 = external_exports.object({
33739
33809
  progressLine: external_exports.string().nullable().optional(),
33740
33810
  reviewPending: external_exports.boolean().default(false),
33741
33811
  dueAt: external_exports.string().nullable().default(null),
33812
+ /** WHEN ITS OWNER SAID DONE WHILE CHILDREN WERE OPEN (#2704): its own work is finished and it closes
33813
+ * with its last open child. Null otherwise; optional, so hand-built queues need not spell it. */
33814
+ finishedAt: external_exports.string().nullable().optional(),
33742
33815
  /** The Goal this one was opened under; null at the root. */
33743
33816
  parentGoalId: external_exports.string().nullable().default(null),
33744
33817
  /** Goals opened under this one — only those the same list holds. */
@@ -33747,8 +33820,17 @@ var QueueItemSchema2 = external_exports.object({
33747
33820
  dependencyGoalIds: external_exports.array(external_exports.string()).default([]),
33748
33821
  /** True while any gate is on a Goal that is not done — the walk draws it dashed. */
33749
33822
  blocked: external_exports.boolean().default(false),
33750
- /** Every decision need on it, open or settled — the page decides which to show. */
33823
+ /** Its questions: every OPEN one, and at most ten settled, newest settled first
33824
+ * (20260929133308) — the page decides which of them to show. NOT the whole set: `asked` and
33825
+ * `answered` are, and a settled one's words are a line (280 characters), its body read when the
33826
+ * question is opened. */
33751
33827
  questions: external_exports.array(QueueQuestionSchema2).default([]),
33828
+ /** HOW MANY QUESTIONS THIS WORK HAS ASKED, and how many are answered — the Goal's own totals,
33829
+ * bounded at 100 server-side. A tally counted off `questions` is a wrong number that looks
33830
+ * right once the cap bites (`walk/trees.ts` `tallyOf`). Optional, and defaulted from the array
33831
+ * by the projection, so hand-built queues (fixtures, the demo) need not spell them. */
33832
+ asked: external_exports.number().optional(),
33833
+ answered: external_exports.number().optional(),
33752
33834
  /** Every note on it the person replied to (`QueueReplySchema`) — the page decides which to show.
33753
33835
  * Optional, not defaulted: absent is none, and every hand-built queue (fixtures, the demo) need
33754
33836
  * not spell an empty list. */
@@ -33883,6 +33965,19 @@ var DeliveryConfigSchema2 = external_exports.object({
33883
33965
  * carries credentials; only a `poll` registration can come back with null here. */
33884
33966
  realtime: external_exports.object({ url: external_exports.string(), anonKey: external_exports.string() }).nullable()
33885
33967
  });
33968
+ var HostDecisionSchema2 = external_exports.object({
33969
+ /** The agent's token id: the row's `recipient`. */
33970
+ agent: external_exports.string().uuid(),
33971
+ decision: external_exports.enum(["stood_back", "took_over"]),
33972
+ /** The work it was about: the Goal `claim_goal` would hand that agent next. */
33973
+ goalId: external_exports.string().uuid().nullable().optional(),
33974
+ /** When the server last heard from the agent, as the host read it: the presence it stood back for. */
33975
+ seenAt: external_exports.string().datetime().nullable().optional(),
33976
+ /** When that work last moved (`claimable.since` on `check_replies`), the fact the bound is judged on. */
33977
+ since: external_exports.string().datetime().nullable().optional(),
33978
+ /** What the host said, in its log's own words: why it stood back, or what the take-over did. */
33979
+ said: external_exports.string().max(300).optional()
33980
+ });
33886
33981
  var WakeNudgeSchema2 = external_exports.object({
33887
33982
  kind: external_exports.enum(["reply", "request", "callback"]),
33888
33983
  notificationId: external_exports.string().optional(),
@@ -33927,6 +34022,9 @@ var DeviceTokenSchema2 = external_exports.object({
33927
34022
  * landed in the FIRST granted workspace and the agent rediscovered its own repo from
33928
34023
  * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
33929
34024
  workspace: external_exports.string().nullable().optional(),
34025
+ /** Local host recovery must preserve the launch's runtime and Paigy identity. */
34026
+ harness: external_exports.enum(["claude", "codex", "agy"]).optional(),
34027
+ session_id: external_exports.string().uuid().optional(),
33930
34028
  uik_pub: external_exports.string().nullable().optional()
33931
34029
  });
33932
34030
  var SupportRequestSchema2 = external_exports.object({
@@ -34146,6 +34244,14 @@ var CARRY_ON = "The usage limit that stopped your last turn has reset. Carry on
34146
34244
  var OWED = "That turn was given this when the limit stopped it, and may not have got to it. Act on it now, unless you already have:";
34147
34245
  var names = (list) => list.length <= 1 ? list[0] ?? "" : `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
34148
34246
  var GATHER_MS = 9e4;
34247
+ function outcome(result, paused) {
34248
+ const r = result ?? {};
34249
+ const who = `${paused.length} paused agent${paused.length === 1 ? "" : "s"}: ${names(paused)}`;
34250
+ if (r.sent === false) return `\u26A0 the limit call did not ring (${who}): it was filed as progress, nobody was notified`;
34251
+ if (r.kind === "call" && typeof r.deliveryId === "string") return `\u260E one call for ${who}, placed (delivery ${r.deliveryId})`;
34252
+ if (typeof r.deliveryId === "string") return `\u26A0 the limit call did not ring (${who}): it went out as a card (delivery ${r.deliveryId})`;
34253
+ return `\u26A0 the limit call did not ring (${who}): no delivery came back`;
34254
+ }
34149
34255
  function episodes(opts = {}) {
34150
34256
  const gather2 = opts.gatherMs ?? GATHER_MS;
34151
34257
  const later = opts.timer ?? ((fn, ms) => void setTimeout(fn, ms).unref?.());
@@ -34177,8 +34283,10 @@ function episodes(opts = {}) {
34177
34283
  `Claude's usage limit paused ${names(episode.paused)} from ${clock3(episode.since)}.`,
34178
34284
  rest.length ? `It has reset: ${names(episode.running)} ${episode.running.length > 1 ? "are" : "is"} running again, and ${names(rest)} ${rest.length > 1 ? "haven't" : "hasn't"} started again yet.` : `It has reset, and ${episode.paused.length > 1 ? "they have all" : "it has"} carried on.`
34179
34285
  ].join(" ");
34180
- log?.(`\u260E one call for ${episode.paused.length} paused agent${episode.paused.length === 1 ? "" : "s"}: ${names(episode.paused)}`);
34181
- void call(text).catch((error) => log?.(`\u26A0 could not place the limit call: ${error.message}`));
34286
+ void call(text).then(
34287
+ (result) => log?.(outcome(result, episode.paused)),
34288
+ (error) => log?.(`\u26A0 could not place the limit call: ${error.message}`)
34289
+ );
34182
34290
  }, gather2);
34183
34291
  }
34184
34292
  };
@@ -34301,7 +34409,7 @@ ${event.result?.trim() || "No result reported."}`, state, rail, opts);
34301
34409
 
34302
34410
  // src/paigy/conversation.ts
34303
34411
  import { randomUUID as randomUUID5 } from "crypto";
34304
- var HARNESS_INSTRUCTION = "You are running as an unattended background harness agent; your user reaches you through Paigy and is not at this terminal. Nothing wakes you after your turn ends but the person, so wait on CI or a deploy inside the turn. The person never sees your turn's final message: it is kept as progress on this Goal. Anything the person needs \u2014 a question, a result, a blocker \u2014 you send through Paigy's contact tool, once; the harness sends nothing on your behalf. What the person says later arrives with the Goal it was said on named in front of it; answer them on that Goal (its id as the contact's parentId). " + DONE_MESSAGE;
34412
+ var HARNESS_INSTRUCTION = "You are running as an unattended background harness agent; your user reaches you through Paigy and is not at this terminal. Nothing wakes you after your turn ends but the person, so wait on CI or a deploy inside the turn. The person never sees your turn's final message: it is kept as progress on this Goal. Anything the person needs \u2014 a question, a result, a blocker \u2014 you send through Paigy's contact tool, once; the harness sends nothing on your behalf. What the person says later arrives with the Goal it was said on named in front of it; answer them on that Goal (its id as the contact's parentId).";
34305
34413
  var SAFETY_MS = 6e4;
34306
34414
  var ASK_BEAT_MS = 5e3;
34307
34415
  function beat() {
@@ -34429,9 +34537,9 @@ async function gather(state, rail, held) {
34429
34537
  var GIVEN = "This Goal was given to you, and nobody has started it. Carry on with what you're doing unless it changes that; claim_goal with its id when you take it up.";
34430
34538
  var says = (h) => h.words.length > 0 || h.via === "assigned";
34431
34539
  function whatItIs(h) {
34432
- const outcome = h.brief.outcome?.trim() ?? "";
34433
- const name = (h.title ?? outcome).trim().split("\n")[0].trim();
34434
- return outcome && outcome !== name ? [outcome] : [];
34540
+ const outcome2 = h.brief.outcome?.trim() ?? "";
34541
+ const name = (h.title ?? outcome2).trim().split("\n")[0].trim();
34542
+ return outcome2 && outcome2 !== name ? [outcome2] : [];
34435
34543
  }
34436
34544
  var block = (h) => [
34437
34545
  ...h.via === "assigned" ? [GIVEN] : [],
@@ -34558,11 +34666,11 @@ async function look(session, state, rail, opts) {
34558
34666
  state.goal = { id: brief2.goalId, revision: brief2.revision };
34559
34667
  state.below = baseline(brief2.goalId, brief2.subtree);
34560
34668
  }
34561
- const outcome = brief2.outcome?.trim() ?? "";
34562
- const said = (brief2.entries ?? []).map((e) => entryWords(e).trim()).filter((w) => w && w.toLowerCase() !== outcome.toLowerCase());
34669
+ const outcome2 = brief2.outcome?.trim() ?? "";
34670
+ const said = (brief2.entries ?? []).map((e) => entryWords(e).trim()).filter((w) => w && w.toLowerCase() !== outcome2.toLowerCase());
34563
34671
  const left = brief2.earlier ? brief2.earlier.omitted + brief2.earlier.shortened : 0;
34564
34672
  const earlier = left ? `(${left} earlier entries on this Goal are left out or shortened; get_goal with history: true reads them in full.)` : "";
34565
- const fullText = [outcome, earlier, ...said].filter(Boolean).join("\n\n");
34673
+ const fullText = [outcome2, earlier, ...said].filter(Boolean).join("\n\n");
34566
34674
  send2(session, state, [HARNESS_INSTRUCTION, fullText].filter(Boolean).join("\n\n"));
34567
34675
  return;
34568
34676
  }
@@ -34748,17 +34856,33 @@ ${event.message}`, state, rail, { log: opts.log });
34748
34856
 
34749
34857
  // src/host.ts
34750
34858
  init_dist();
34751
- init_catalog();
34752
34859
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
34753
- import { homedir as homedir8 } from "os";
34860
+ import { homedir as homedir9 } from "os";
34754
34861
  import { join as join9 } from "path";
34755
34862
 
34863
+ // src/broker.ts
34864
+ init_evidence();
34865
+ init_triage();
34866
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
34867
+ function ago(iso, now) {
34868
+ const ms = now - Date.parse(iso);
34869
+ if (!Number.isFinite(ms) || ms < 6e4) return "just now";
34870
+ const minutes = Math.floor(ms / 6e4);
34871
+ if (minutes < 60) return `${plural2(minutes, "minute")} ago`;
34872
+ const hours = Math.floor(minutes / 60);
34873
+ if (hours < 24) return `${plural2(hours, "hour")} ago`;
34874
+ return `${plural2(Math.floor(hours / 24), "day")} ago`;
34875
+ }
34876
+
34877
+ // src/host.ts
34878
+ init_catalog();
34879
+
34756
34880
  // src/workspaces.ts
34757
34881
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
34758
34882
  import { dirname as dirname2, join as join5, resolve as resolve2 } from "path";
34759
- import { homedir as homedir5 } from "os";
34883
+ import { homedir as homedir6 } from "os";
34760
34884
  function workspacesFile() {
34761
- return join5(homedir5(), ".paigy", "workspaces.json");
34885
+ return join5(homedir6(), ".paigy", "workspaces.json");
34762
34886
  }
34763
34887
  function listWorkspaces(deps) {
34764
34888
  try {
@@ -34769,7 +34893,7 @@ function listWorkspaces(deps) {
34769
34893
  }
34770
34894
  }
34771
34895
  function addWorkspace(dir, deps) {
34772
- const next = resolve2(dir.replace(/^~(?=$|\/)/, homedir5()));
34896
+ const next = resolve2(dir.replace(/^~(?=$|\/)/, homedir6()));
34773
34897
  const all = listWorkspaces(deps);
34774
34898
  if (!all.includes(next)) {
34775
34899
  all.push(next);
@@ -34779,12 +34903,12 @@ function addWorkspace(dir, deps) {
34779
34903
  return all;
34780
34904
  }
34781
34905
  function allowed(cwd, deps) {
34782
- const target = resolve2(cwd.replace(/^~(?=$|\/)/, homedir5()));
34906
+ const target = resolve2(cwd.replace(/^~(?=$|\/)/, homedir6()));
34783
34907
  return listWorkspaces(deps).some((w) => target === w || target.startsWith(`${w}/`));
34784
34908
  }
34785
34909
  function resolveWakeDir(pinned, deps) {
34786
34910
  if (pinned && allowed(pinned, deps)) {
34787
- const dir = resolve2(pinned.replace(/^~(?=$|\/)/, homedir5()));
34911
+ const dir = resolve2(pinned.replace(/^~(?=$|\/)/, homedir6()));
34788
34912
  if ((deps.exists ?? existsSync4)(dir)) return dir;
34789
34913
  }
34790
34914
  return listWorkspaces(deps)[0];
@@ -34794,7 +34918,7 @@ function resolveWakeDir(pinned, deps) {
34794
34918
  init_dist();
34795
34919
  import { execFileSync as execFileSync3 } from "child_process";
34796
34920
  import { readFileSync as readFileSync4, readdirSync as readdirSync2 } from "fs";
34797
- import { homedir as homedir6 } from "os";
34921
+ import { homedir as homedir7 } from "os";
34798
34922
  import { join as join6 } from "path";
34799
34923
  function isAlive(pid) {
34800
34924
  try {
@@ -34825,7 +34949,7 @@ function holds(pid, procStart, deps) {
34825
34949
  return false;
34826
34950
  }
34827
34951
  function localHolders(deps = {}) {
34828
- const dir = deps.dir ?? join6(process.env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir6(), ".claude"), "sessions");
34952
+ const dir = deps.dir ?? join6(process.env.CLAUDE_CONFIG_DIR?.trim() || join6(homedir7(), ".claude"), "sessions");
34829
34953
  const list = deps.list ?? ((d) => readdirSync2(d));
34830
34954
  const read = deps.read ?? ((f) => readFileSync4(f, "utf8"));
34831
34955
  const check = { alive: deps.alive ?? isAlive, started: deps.started ?? psStart };
@@ -34855,7 +34979,7 @@ function localHolders(deps = {}) {
34855
34979
 
34856
34980
  // src/owner.ts
34857
34981
  import { closeSync as closeSync2, openSync as openSync2, readSync, readdirSync as readdirSync3 } from "fs";
34858
- import { homedir as homedir7 } from "os";
34982
+ import { homedir as homedir8 } from "os";
34859
34983
  import { join as join7 } from "path";
34860
34984
  var HEAD_BYTES = 64e3;
34861
34985
  function readHead(file) {
@@ -34872,7 +34996,7 @@ function ownerSession(slot, deps = {}) {
34872
34996
  const m = /^session:([0-9a-f]{8})$/.exec(slot);
34873
34997
  if (!m) return null;
34874
34998
  const prefix = m[1];
34875
- const root = deps.projectsDir ?? join7(homedir7(), ".claude", "projects");
34999
+ const root = deps.projectsDir ?? join7(homedir8(), ".claude", "projects");
34876
35000
  const list = deps.list ?? ((dir) => readdirSync3(dir));
34877
35001
  const head = deps.head ?? readHead;
34878
35002
  let folders;
@@ -34990,34 +35114,51 @@ function selfUpdater(deps) {
34990
35114
  }
34991
35115
 
34992
35116
  // src/host.ts
34993
- var HOST_FILE = join9(homedir8(), ".paigy", "host.json");
35117
+ var HOST_FILE = join9(homedir9(), ".paigy", "host.json");
35118
+ function wakeHarness(slot, saved, claudeTranscript) {
35119
+ if (slot === "Desktop") return void 0;
35120
+ if (saved !== null) return saved === "agy" || saved === "codex" || saved === "claude" ? saved : void 0;
35121
+ if (claudeTranscript) return "claude";
35122
+ if (slot === "mcp-agent" || slot === "Claude Code") return "claude";
35123
+ if (slot === "codex") return "codex";
35124
+ if (slot === "antigravity") return "agy";
35125
+ return void 0;
35126
+ }
34994
35127
  var runKey = (slot) => `slot:${slot}`;
34995
35128
  var claimedRun = (sessionId2) => {
34996
35129
  const slot = sessionSlot(sessionId2);
34997
35130
  return { slot, key: runKey(slot) };
34998
35131
  };
34999
35132
  var launchRun = (spec) => ({ session: spec.sessionId });
35133
+ var NOTHING_WAITING = "has nothing waiting";
35000
35134
  async function waitingWork(token, session) {
35001
35135
  let found;
35002
35136
  try {
35003
35137
  found = await checkReplies({ token, ...session ? { reach: reachAs(session) } : {} });
35004
35138
  } catch (error) {
35139
+ if (error instanceof RevokedError) return { skip: "was revoked: its credential is gone for good", revoked: true };
35140
+ if (error instanceof ApiError && error.body?.error === "identity_session_mismatch") {
35141
+ return { skip: "is bound to the session it was paired in, which this host cannot name \u2014 run `paigy-harness handoff` as it, in its folder, to record it" };
35142
+ }
35005
35143
  return { skip: `could not read what is waiting (${error.message})` };
35006
35144
  }
35007
- const n = found.deliveries.length;
35008
- if (n) return { waiting: n === 1 ? "a message" : `${n} messages` };
35009
35145
  const next = found.claimable;
35010
- return next ? { waiting: `Goal ${next.goalId.slice(0, 8)}${next.title ? ` "${next.title}"` : ""}` } : { skip: "has nothing waiting" };
35146
+ const about = next ? { goalId: next.goalId, ...next.since ? { since: next.since } : {} } : {};
35147
+ const n = found.deliveries.length;
35148
+ if (n) return { waiting: n === 1 ? "a message" : `${n} messages`, ...about };
35149
+ return next ? { waiting: `Goal ${next.goalId.slice(0, 8)}${next.title ? ` "${next.title}"` : ""}`, ...about } : { skip: NOTHING_WAITING };
35011
35150
  }
35012
35151
  function evict(runs, key, run) {
35013
35152
  if (runs.get(key)?.run === run) runs.delete(key);
35014
35153
  }
35015
35154
  var PRESENCE_FRESH_MS = 3 * 6e4;
35155
+ var IDLE_HOLDER_MS = 30 * 6e4;
35016
35156
  function wakeAction(live, now, serverSeenAt, here = {}) {
35017
35157
  if (live) return { action: "skip", why: "run" };
35018
35158
  if (here.holder) return { action: "skip", why: "holder" };
35019
35159
  if (serverSeenAt !== void 0 && now - serverSeenAt < PRESENCE_FRESH_MS) {
35020
35160
  if (here.goneAt !== void 0 && serverSeenAt <= here.goneAt) return { action: "spawn", why: "gone" };
35161
+ if (here.movedAt !== void 0 && now - here.movedAt >= IDLE_HOLDER_MS) return { action: "spawn", why: "idle" };
35021
35162
  return { action: "skip", why: "presence" };
35022
35163
  }
35023
35164
  return { action: "spawn", why: "nobody" };
@@ -35027,9 +35168,45 @@ function startHost(opts) {
35027
35168
  const asHost = { token: opts.token };
35028
35169
  const goneAt = /* @__PURE__ */ new Map();
35029
35170
  const heldHere = /* @__PURE__ */ new Set();
35171
+ const revoked = /* @__PURE__ */ new Map();
35172
+ const isRevoked = (slot) => {
35173
+ const dead = revoked.get(slot);
35174
+ if (dead === void 0) return false;
35175
+ if (readToken(slot) === dead) return true;
35176
+ revoked.delete(slot);
35177
+ return false;
35178
+ };
35179
+ function drop(slot, token) {
35180
+ if (revoked.get(slot) === token) return;
35181
+ revoked.set(slot, token);
35182
+ const sub = listening.get(slot);
35183
+ if (sub) {
35184
+ listening.delete(slot);
35185
+ void sub.close().catch(() => {
35186
+ });
35187
+ }
35188
+ opts.log(`\u2716 ${slotName(slot) ?? slot} was revoked \u2014 its credential is gone for good, so this host stops presenting it (dropped from the roster; pairing again makes a new identity)`);
35189
+ }
35190
+ const recorded = /* @__PURE__ */ new Map();
35191
+ function decide(slot, decision, found, seenAt, said) {
35192
+ const agent2 = slotIdentity(slot).tokenId;
35193
+ if (!agent2) return;
35194
+ const goalId = found?.goalId;
35195
+ const key = `${decision}:${goalId ?? ""}`;
35196
+ if (recorded.get(slot) === key) return;
35197
+ recorded.set(slot, key);
35198
+ void recordDecision({
35199
+ agent: agent2,
35200
+ decision,
35201
+ goalId: goalId ?? null,
35202
+ seenAt: seenAt === void 0 ? null : new Date(seenAt).toISOString(),
35203
+ since: found?.since ?? null,
35204
+ said: said.slice(0, 300)
35205
+ }, asHost).catch((e) => opts.log(`\u25CC could not record that ${slotName(slot) ?? slot} ${decision.replace("_", " ")} (${e.message})`));
35206
+ }
35030
35207
  const refreshIdentities = async () => {
35031
35208
  for (const slot of listSlots()) {
35032
- if (slot === "Desktop") continue;
35209
+ if (slot === "Desktop" || isRevoked(slot)) continue;
35033
35210
  const token = readToken(slot);
35034
35211
  if (!token) continue;
35035
35212
  const me = await whoAmI({ token }).catch(() => null);
@@ -35067,6 +35244,19 @@ function startHost(opts) {
35067
35244
  const label = spec.name ?? spec.harness;
35068
35245
  const { slot, key } = claimedRun(spec.sessionId);
35069
35246
  const log = (line) => opts.log(`[${label}] ${line}`);
35247
+ try {
35248
+ saveToken({
35249
+ access_token: spec.token,
35250
+ name: label,
35251
+ device: null,
35252
+ workspace: spec.workspace,
35253
+ harness: spec.harness,
35254
+ session_id: spec.sessionId
35255
+ }, slot);
35256
+ } catch {
35257
+ log("Cannot persist launch identity/runtime; session was not started. Check local storage and relaunch.");
35258
+ continue;
35259
+ }
35070
35260
  const run = runHarness({
35071
35261
  harness: spec.harness,
35072
35262
  cwd: spec.workspace,
@@ -35086,14 +35276,9 @@ function startHost(opts) {
35086
35276
  void heartbeat(void 0, { token: spec.token }).catch(() => {
35087
35277
  });
35088
35278
  void refreshIdentities();
35089
- try {
35090
- saveToken({ access_token: spec.token, name: label, device: null, workspace: spec.workspace }, slot);
35091
- } catch {
35092
- }
35093
35279
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
35094
35280
  }
35095
35281
  }
35096
- const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex", antigravity: "agy" };
35097
35282
  async function presenceMap() {
35098
35283
  const presence = /* @__PURE__ */ new Map();
35099
35284
  try {
@@ -35114,30 +35299,64 @@ function startHost(opts) {
35114
35299
  let lastHolders = /* @__PURE__ */ new Map();
35115
35300
  async function wakeSlot(slot, { presence, holders }, cause = "sweep") {
35116
35301
  {
35117
- const harness = SLOT_HARNESS[slot] ?? (slot === "Desktop" ? void 0 : "claude");
35118
35302
  const key = runKey(slot);
35119
- if (!harness) return;
35303
+ if (slot === "Desktop" || isRevoked(slot)) return;
35120
35304
  const live = runs.get(key);
35121
35305
  const tokenId = slotIdentity(slot).tokenId;
35122
35306
  const seenByServer = tokenId ? presence.get(tokenId) : void 0;
35123
35307
  const holder = holders.get(slot);
35124
35308
  if (holder) heldHere.add(slot);
35125
35309
  else if (heldHere.delete(slot)) goneAt.set(slot, Date.now());
35126
- const decided = wakeAction(!!live, Date.now(), seenByServer, { holder, goneAt: goneAt.get(slot) });
35127
- if (decided.action === "skip") {
35310
+ const here = { holder, goneAt: goneAt.get(slot) };
35311
+ let decided = wakeAction(!!live, Date.now(), seenByServer, here);
35312
+ const label = slotName(slot) ?? slot;
35313
+ if (decided.action === "skip" && decided.why !== "presence") {
35128
35314
  if (decided.why === "run" && cause === "wake") live?.run.nudge();
35129
- const name = slotName(slot) ?? slot;
35130
- opts.log(decided.why === "run" ? `\u25CC ${name} is running here \u2014 ${cause === "wake" ? "woke its pump" : "its pump types the work in"}; not spawning` : decided.why === "holder" ? `\u25B8 ${name} already running (pid ${holder?.pid ?? "?"}) \u2014 not starting a second` : `\u25CC ${name} is working somewhere this host cannot see (heartbeat) \u2014 not spawning`);
35315
+ opts.log(decided.why === "run" ? `\u25CC ${label} is running here \u2014 ${cause === "wake" ? "woke its pump" : "its pump types the work in"}; not spawning` : `\u25B8 ${label} already running (pid ${holder?.pid ?? "?"}) \u2014 not starting a second`);
35131
35316
  return;
35132
35317
  }
35133
- const label = slotName(slot) ?? slot;
35134
- const skip = (why) => opts.log(`\u25CC ${label} ${why} \u2014 not spawning`);
35318
+ let taking = "";
35319
+ let found;
35320
+ const skip = (why) => {
35321
+ opts.log(`\u25CC ${label} ${why} \u2014 not spawning${taking}`);
35322
+ if (taking && found) decide(slot, "took_over", found, seenByServer, `${why} \u2014 not spawning${taking}`);
35323
+ };
35135
35324
  const token = readToken(slot);
35136
35325
  if (!token) {
35137
35326
  skip("has no token on this machine");
35138
35327
  return;
35139
35328
  }
35140
- const owner = ownerSession(slot);
35329
+ const identity = slotIdentity(slot);
35330
+ const owner = !identity.harness || identity.harness === "claude" ? ownerSession(slot) : null;
35331
+ const read = async () => {
35332
+ const got = await waitingWork(token, identity.sessionId ?? owner?.sessionId);
35333
+ if (!("skip" in got)) return got;
35334
+ if (got.revoked) {
35335
+ drop(slot, token);
35336
+ return null;
35337
+ }
35338
+ skip(got.skip);
35339
+ if (decided.why === "presence" && got.skip !== NOTHING_WAITING) decide(slot, "stood_back", null, seenByServer, got.skip);
35340
+ return null;
35341
+ };
35342
+ if (decided.why === "presence") {
35343
+ found = await read() ?? void 0;
35344
+ if (!found) return;
35345
+ const since = found.since;
35346
+ decided = wakeAction(false, Date.now(), seenByServer, { ...here, ...since ? { movedAt: Date.parse(since) } : {} });
35347
+ if (decided.action === "skip") {
35348
+ const said = `is working somewhere this host cannot see (heartbeat) \u2014 ${found.waiting}${since ? ` last moved ${ago(since, Date.now())}; taken over once it sits ${IDLE_HOLDER_MS / 6e4} min untouched` : ", with nothing to say when it last moved"}`;
35349
+ opts.log(`\u25CC ${label} ${said} \u2014 not spawning`);
35350
+ decide(slot, "stood_back", found, seenByServer, said);
35351
+ return;
35352
+ }
35353
+ taking = ` (taking over: ${found.waiting} last moved ${ago(since, Date.now())} while something this host cannot see heartbeats as ${label} and has not touched it)`;
35354
+ }
35355
+ const harness = wakeHarness(slot, identity.harness, !!owner);
35356
+ if (!harness) {
35357
+ skip("has no recorded harness; select its runtime before recovery");
35358
+ return;
35359
+ }
35141
35360
  if (owner && !allowed(owner.cwd, opts.wsDeps)) {
35142
35361
  skip(`ran in ${owner.cwd}, outside the folders this machine granted`);
35143
35362
  return;
@@ -35147,11 +35366,8 @@ function startHost(opts) {
35147
35366
  skip("has no granted folder to wake in");
35148
35367
  return;
35149
35368
  }
35150
- const found = await waitingWork(token, owner?.sessionId);
35151
- if ("skip" in found) {
35152
- skip(found.skip);
35153
- return;
35154
- }
35369
+ found ??= await read() ?? void 0;
35370
+ if (!found) return;
35155
35371
  if (runs.has(key)) {
35156
35372
  skip("was started by the wake that read it first");
35157
35373
  return;
@@ -35163,6 +35379,7 @@ function startHost(opts) {
35163
35379
  mode: "bypass",
35164
35380
  prompt: "",
35165
35381
  token,
35382
+ ...identity.sessionId ? { session: identity.sessionId } : {},
35166
35383
  ...owner ? { resume: owner.sessionId } : {},
35167
35384
  // A dead run must not squat the slot — evict so the next wake can respawn. Only ITS entry:
35168
35385
  // a run that is no longer the slot's must not take the live one's place away with it.
@@ -35177,13 +35394,15 @@ function startHost(opts) {
35177
35394
  runs.set(key, { run, label, token });
35178
35395
  void heartbeat(void 0, { token }).catch(() => {
35179
35396
  });
35180
- opts.log(`\u25B6 ${owner ? "resumed" : "woke"} ${label} (${harness}) \u2014 ${found.waiting} was waiting, in ${workspace}${decided.why === "gone" ? " (its process here ended; its last heartbeat was not waited out)" : ""}`);
35397
+ const woke = `\u25B6 ${owner ? "resumed" : "woke"} ${label} (${harness}) \u2014 ${found.waiting} was waiting, in ${workspace}${decided.why === "gone" ? " (its process here ended; its last heartbeat was not waited out)" : taking}`;
35398
+ opts.log(woke);
35399
+ if (taking) decide(slot, "took_over", found, seenByServer, woke);
35181
35400
  }
35182
35401
  }
35183
35402
  const listening = /* @__PURE__ */ new Map();
35184
35403
  const joining = /* @__PURE__ */ new Set();
35185
35404
  async function listenSlots() {
35186
- const want = new Set(listSlots().filter((slot) => slot !== "Desktop" && !!readToken(slot)));
35405
+ const want = new Set(listSlots().filter((slot) => slot !== "Desktop" && !!readToken(slot) && !isRevoked(slot)));
35187
35406
  for (const [slot, sub] of [...listening]) {
35188
35407
  if (want.has(slot)) continue;
35189
35408
  listening.delete(slot);
@@ -35204,6 +35423,10 @@ function startHost(opts) {
35204
35423
  listening.set(slot, sub);
35205
35424
  opts.log(`\u25C9 listening for ${slotName(slot) ?? slot}`);
35206
35425
  } catch (e) {
35426
+ if (e instanceof RevokedError) {
35427
+ drop(slot, token);
35428
+ continue;
35429
+ }
35207
35430
  opts.log(`\u25CC not listening for ${slotName(slot) ?? slot} \u2014 ${e.message}`);
35208
35431
  } finally {
35209
35432
  joining.delete(slot);
@@ -35269,7 +35492,7 @@ function startHost(opts) {
35269
35492
  roster: () => {
35270
35493
  const live = new Map([...runs.values()].map((r) => [r.label, r.run]));
35271
35494
  const names2 = /* @__PURE__ */ new Map();
35272
- for (const key of listSlots()) if (key !== "Desktop") names2.set(slotName(key) ?? key, key);
35495
+ for (const key of listSlots()) if (key !== "Desktop" && !isRevoked(key)) names2.set(slotName(key) ?? key, key);
35273
35496
  for (const label of live.keys()) if (!names2.has(label)) names2.set(label, null);
35274
35497
  return [...names2.entries()].map(([name, slot]) => {
35275
35498
  const id = slot ? slotIdentity(slot) : { voice: null, tokenId: null };
@@ -35337,17 +35560,17 @@ function startBridgeInstalls(bridges, deps = {}) {
35337
35560
  const sleep3 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
35338
35561
  const running = bridges.map((b) => {
35339
35562
  let proc = null;
35340
- let outcome;
35563
+ let outcome2;
35341
35564
  try {
35342
35565
  proc = spawn2(b.install);
35343
- outcome = proc.exited.then(
35566
+ outcome2 = proc.exited.then(
35344
35567
  (code) => code === 0 ? "installed" : "failed",
35345
35568
  () => "failed"
35346
35569
  );
35347
35570
  } catch {
35348
- outcome = Promise.resolve("failed");
35571
+ outcome2 = Promise.resolve("failed");
35349
35572
  }
35350
- return { ...b, proc, outcome };
35573
+ return { ...b, proc, outcome: outcome2 };
35351
35574
  });
35352
35575
  return {
35353
35576
  started: running.length,
@@ -35625,7 +35848,7 @@ async function main() {
35625
35848
  process.exit(1);
35626
35849
  }
35627
35850
  const cwd = process.cwd();
35628
- if (!updateSlot(slot, { workspace: cwd })) {
35851
+ if (!updateSlot(slot, { workspace: cwd, session_id: sessionId() })) {
35629
35852
  console.error(`Slot "${slot}" isn't in this machine's slot file \u2014 this session was spawned by the host, which already owns its wake dir. Run handoff from the terminal that hatched the identity.`);
35630
35853
  process.exit(1);
35631
35854
  }