@paigy/harness 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +121 -14
  2. package/dist/main.js +91 -7
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -23655,10 +23655,17 @@ function contactSchemaFrom(fields) {
23655
23655
  ),
23656
23656
  workId: fields.workId.describe(
23657
23657
  "The durable Work this contact advances. Pass the workId from check_replies or a prior reply when asking for a decision that blocks that work."
23658
+ ),
23659
+ // THE WAIT, CONTINUED (owner, 2026-09-06: "await should have been folded into contact").
23660
+ // A contact that rang holds its first window itself; the host caps one tool call at
23661
+ // ~60 s, so keeping the line is another contact — with ONLY this field. Nothing is sent.
23662
+ wait: external_exports.string().uuid().optional().describe(
23663
+ "KEEP WAITING on a live call: the notificationId a previous contact returned. Send it ALONE \u2014 no ask, nothing new goes to the user; contact just holds the next ~45 s window and returns the outcome in `wait`."
23658
23664
  )
23659
23665
  });
23660
23666
  const out = mcpInputSchema(surface);
23661
- out.required = ["ask"];
23667
+ delete out.required;
23668
+ out.anyOf = [{ required: ["ask"] }, { required: ["wait"] }];
23662
23669
  return out;
23663
23670
  }
23664
23671
  function normalizeWaiting(req) {
@@ -24334,7 +24341,7 @@ async function patchState(path, state, opts, operation) {
24334
24341
  function setTaskState(notificationId, state, opts = {}) {
24335
24342
  return patchState(`/api/notify/${encodeURIComponent(notificationId)}/state`, state, opts, "set_task_state");
24336
24343
  }
24337
- var require2, __create2, __defProp2, __getOwnPropDesc2, __getOwnPropNames2, __getProtoOf2, __hasOwnProp2, __require2, __commonJS2, __copyProps2, __toESM2, require_nacl_fast, BACKEND_URL, NETWORK_MSG, PROXY_ENV, agent, INSTANCE_ID, WORKSPACE_ID, envSession, SESSION_ID, 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, MISSED_CALL_PLAN, CreateGoalSchema, UpdateGoalSchema, fmtMin, STANDARD_MEANS, CONTACT_DESCRIPTION, ContextSchema, ParticipantSchema, TransformSchema, OptionSchema, VisualSchema, NotifyLevelSchema, SelectShapeSchema, ReceiptEventSchema, AttentionSchema, NotifyRequestFields, NotifyRequestSchema, NotifyStatusSchema, AgentStateSchema, SetTaskStateSchema, SetWorkStateSchema, TurnSchema, UserAnswerSchema, IntentSchema, RideAlongSchema, AwaitItemSchema, CallbackTriggerSchema, ScheduleCallbackSchema, PendingRepliesSchema, NotifyResponseSchema, NotifyPlanUnitSchema, NotifyPlanSchema, UserResponseSchema, VoiceKeySchema, AgendaTurnSchema, CLAIM_STALE_MS, InboxItemSchema, SnoozeRequestSchema, APNS_TOKEN_RE, PushTokenSchema, MissedCallSchema, BrokerTuningSchema, UserSettingsSchema, HistoryItemSchema, ACTIVITY_LINES, ACTIVITY_LINE_MAX, AgentActivitySchema, ConnectionSummarySchema, MoveRingSchema, MoveSchema, CreateRequestSchema, HandoffSchema, NoteSourceSchema, NoteStatusSchema, NoteRepeatSchema, DecisionSchema, NoteSchema, CreateNoteSchema, RecordDecisionSchema, AssignNoteSchema, TriageVerdictSchema, TriageItemSchema, TriageAssignmentSchema, TriageStatusSchema, SubmitTriageSchema, TriageProposalSchema, AcceptTriageSchema, AcceptTriageResultSchema, DeliveryModeSchema, RegisterDeliverySchema, OAuthStartSchema, DeliveryConfigSchema, StatusSchema, EnvelopeRecipientSchema, EnvelopeHeaderSchema, EnvelopeSchema, SealedAnswerSchema, DeviceCredentialSchema, DeviceRosterSchema, WakeNudgeSchema, PairingStatusSchema, PairingRevealSchema, DeviceCodeRequestSchema, DeviceCodeSchema, DeviceInfoSchema, DeviceTokenRequestSchema, DeviceTokenSchema, DeviceCommitRequestSchema, DevicePeerCommitSchema, SupportRequestSchema, NotificationFeedbackKindSchema, NotificationFeedbackSchema, FeedbackResolutionSchema, FeedbackOutcomeSchema, CONTACT_SCHEMA, import_tweetnacl, import_tweetnacl2, import_tweetnacl3, import_tweetnacl4, import_tweetnacl5, B64, ok, ENVELOPE_VERSION, ENVELOPE_ALG, AGENT_NAME, TOKEN_PATH, KEY_PATH, sleep, UnpairedError, tokenOverride, authToken, sleep3, ReplyLeaseExpiredError, ReplyAckError;
24344
+ var require2, __create2, __defProp2, __getOwnPropDesc2, __getOwnPropNames2, __getProtoOf2, __hasOwnProp2, __require2, __commonJS2, __copyProps2, __toESM2, require_nacl_fast, BACKEND_URL, NETWORK_MSG, PROXY_ENV, agent, INSTANCE_ID, WORKSPACE_ID, envSession, SESSION_ID, 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, MISSED_CALL_PLAN, CreateGoalSchema, UpdateGoalSchema, fmtMin, STANDARD_MEANS, CONTACT_DESCRIPTION, ContextSchema, ParticipantSchema, TransformSchema, OptionSchema, VisualSchema, NotifyLevelSchema, SelectShapeSchema, ReceiptEventSchema, AttentionSchema, NotifyRequestFields, NotifyRequestSchema, NotifyStatusSchema, AgentStateSchema, SetTaskStateSchema, SetWorkStateSchema, TurnSchema, UserAnswerSchema, IntentSchema, RideAlongSchema, AwaitItemSchema, CallbackTriggerSchema, ScheduleCallbackSchema, PendingRepliesSchema, NotifyResponseSchema, NotifyPlanUnitSchema, NotifyPlanSchema, UserResponseSchema, VoiceKeySchema, AgendaTurnSchema, CLAIM_STALE_MS, InboxItemSchema, SnoozeRequestSchema, APNS_TOKEN_RE, PushTokenSchema, MissedCallSchema, BrokerTuningSchema, UserSettingsSchema, HistoryItemSchema, ACTIVITY_LINES, ACTIVITY_LINE_MAX, AgentActivitySchema, ConnectionSummarySchema, LedgerItemSchema, AgentLedgerSchema, ReassignResultSchema, MoveRingSchema, MoveSchema, CreateRequestSchema, HandoffSchema, NoteSourceSchema, NoteStatusSchema, NoteRepeatSchema, DecisionSchema, NoteSchema, CreateNoteSchema, RecordDecisionSchema, AssignNoteSchema, TriageVerdictSchema, TriageItemSchema, TriageAssignmentSchema, TriageStatusSchema, SubmitTriageSchema, TriageProposalSchema, AcceptTriageSchema, AcceptTriageResultSchema, DeliveryModeSchema, RegisterDeliverySchema, OAuthStartSchema, DeliveryConfigSchema, StatusSchema, EnvelopeRecipientSchema, EnvelopeHeaderSchema, EnvelopeSchema, SealedAnswerSchema, DeviceCredentialSchema, DeviceRosterSchema, WakeNudgeSchema, PairingStatusSchema, PairingRevealSchema, DeviceCodeRequestSchema, DeviceCodeSchema, DeviceInfoSchema, DeviceTokenRequestSchema, DeviceTokenSchema, DeviceCommitRequestSchema, DevicePeerCommitSchema, SupportRequestSchema, NotificationFeedbackKindSchema, NotificationFeedbackSchema, FeedbackResolutionSchema, FeedbackOutcomeSchema, CONTACT_SCHEMA, import_tweetnacl, import_tweetnacl2, import_tweetnacl3, import_tweetnacl4, import_tweetnacl5, B64, ok, ENVELOPE_VERSION, ENVELOPE_ALG, AGENT_NAME, TOKEN_PATH, KEY_PATH, sleep, UnpairedError, tokenOverride, authToken, sleep3, ReplyLeaseExpiredError, ReplyAckError;
24338
24345
  var init_dist = __esm({
24339
24346
  "../../packages/sdk/dist/index.js"() {
24340
24347
  "use strict";
@@ -27010,7 +27017,7 @@ var init_dist = __esm({
27010
27017
  const list = parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}` : parts[0] ?? "";
27011
27018
  return `Rings again ${list} after the missed call, then leaves it in your inbox`;
27012
27019
  })();
27013
- CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId? } \u2014 pass notificationId to the reply path named by the returned \`message\`, and parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim.`;
27020
+ CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId?, wait? }. WHEN IT RANG, contact holds the first ~45 s window ITSELF and \`wait\` carries the outcome: { type:'reply', answer } to act on; { type:'partial', inFlight:true, turn } \u2014 what the user is saying to each turn, provisional: use it to PREPARE (fetch, draft, warm the build), never to act irreversibly, they can still revise it until the final reply (partial = intelligence, settled = authorization; if a partial's acts carry a question aimed at you and you know the answer, contact on the SAME parentId right away \u2014 they hear it on the same call); { type:'remind', remindInSeconds } \u2014 schedule a wake-up; { type:'idle' } \u2014 still waiting. TO KEEP WAITING, call contact again with ONLY { wait: notificationId } \u2014 no ask, nothing new is sent; it holds the next scoped ~45 s window (under the 60 s host cap, so it always returns) and never returns another notification's reply. Keep doing that until the reply \u2014 THAT one is the decision \u2014 so the user steps away and comes back to find you already continued; stop only to do other work and check back, or after an unreasonably long stretch worth telling them about. A message delivery has NO \`wait\`: never poll for it \u2014 the reply arrives through check_replies or your wake. Pass parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim. READING A CALL'S REPLY: it can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a question that blocks me'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's parentId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry \`intents\` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' ("call me after lunch") \u2192 register it NOW with schedule_callback \u2014 when the intent carries \`dueInSeconds\` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' ("you pick") \u2192 make the call yourself and tell them what you chose; 'channel' ("text me next time") \u2192 honor it on your next contact (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped "should I call you back?" because the follow-up is implied. \`transcript\` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions ("yes, IF tests pass") before acting. If your ask declared \`points\`, the reply carries \`covered\` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (contact on the same parentId) or proceed knowingly partial; never treat a partial answer as complete.`;
27014
27021
  ContextSchema = external_exports.object({
27015
27022
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
27016
27023
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -27364,11 +27371,21 @@ var init_dist = __esm({
27364
27371
  acts: external_exports.array(IntentSchema).nullable().optional()
27365
27372
  })
27366
27373
  }),
27367
- external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema).optional() })
27374
+ external_exports.object({
27375
+ type: external_exports.literal("idle"),
27376
+ also: external_exports.array(RideAlongSchema).optional(),
27377
+ /** Is a call live for this agent's user right now? The SDK polls the partial stream
27378
+ * (#783) between idle ticks ONLY while this is not `false` — a partial can only exist
27379
+ * during a live call, and polling for one on a banner/message was a wasted HTTP call +
27380
+ * 3 queries on every idle tick of every waiting agent (~80% of all traffic at scale).
27381
+ * Absent = an older API → the SDK keeps polling, exactly as before. */
27382
+ inFlight: external_exports.boolean().optional()
27383
+ })
27368
27384
  ]);
27369
27385
  CallbackTriggerSchema = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
27370
27386
  ScheduleCallbackSchema = external_exports.object({
27371
27387
  parentId: external_exports.string().describe("The thread to call back on (from a prior contact / reply / request)."),
27388
+ goalId: external_exports.string().uuid().optional().describe("The Goal this callback advances; preferred for Goal-owned work."),
27372
27389
  trigger: CallbackTriggerSchema,
27373
27390
  dueInSeconds: external_exports.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
27374
27391
  note: external_exports.string().optional().describe("What to tell the user when you follow up.")
@@ -27813,6 +27830,15 @@ var init_dist = __esm({
27813
27830
  ConnectionSummarySchema = external_exports.object({
27814
27831
  /** The connection = the agent's token id (used to address a request). */
27815
27832
  id: external_exports.string(),
27833
+ /** The credential kind: "device" = a paired machine (mint-only — it hosts and mints, it
27834
+ * never talks); "agent" = an identity that sends. The roster and devices surfaces split
27835
+ * on this. Optional/absent reads as "agent" (a row predating the kind column). See
27836
+ * apps/api/src/tokens/devices-vs-agents-design.md. */
27837
+ kind: external_exports.enum(["device", "agent"]).optional(),
27838
+ /** For an agent, the token id of the DEVICE that minted it — so agents group under their
27839
+ * machine, and revoking a device cascades to them. Null on devices, and on unlinked
27840
+ * agents (phone-launched, provider-managed, or minted before the link existed). */
27841
+ mintedByDevice: external_exports.string().nullable().optional(),
27816
27842
  device: external_exports.string().nullable(),
27817
27843
  /** The agent's display name (the single pairing name). */
27818
27844
  name: external_exports.string(),
@@ -27852,6 +27878,22 @@ var init_dist = __esm({
27852
27878
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
27853
27879
  managed: external_exports.boolean()
27854
27880
  });
27881
+ LedgerItemSchema = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
27882
+ AgentLedgerSchema = external_exports.object({
27883
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
27884
+ /** Its own questions you have not answered. */
27885
+ asks: external_exports.array(LedgerItemSchema),
27886
+ /** Its questions you answered that nobody acted on — still owed to somebody. */
27887
+ answered: external_exports.array(LedgerItemSchema),
27888
+ /** Requests you sent it that it never took. */
27889
+ requests: external_exports.array(LedgerItemSchema),
27890
+ goals: external_exports.array(external_exports.object({ id: external_exports.string(), outcome: external_exports.string(), state: external_exports.string() })),
27891
+ callbacks: external_exports.array(external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), trigger: external_exports.string(), note: external_exports.string(), dueAt: external_exports.string().nullable() }))
27892
+ });
27893
+ ReassignResultSchema = external_exports.object({
27894
+ moved: external_exports.object({ asks: external_exports.number(), answered: external_exports.number(), requests: external_exports.number(), goals: external_exports.number(), callbacks: external_exports.number() }),
27895
+ parentId: external_exports.string().nullable()
27896
+ });
27855
27897
  MoveRingSchema = external_exports.enum(["home", "travels", "retired", "quarantined"]);
27856
27898
  MoveSchema = external_exports.object({
27857
27899
  id: external_exports.string(),
@@ -28262,7 +28304,7 @@ var init_dist = __esm({
28262
28304
  ReplyLeaseExpiredError = class extends Error {
28263
28305
  constructor() {
28264
28306
  super(
28265
- "The Paigy reply lease expired before acknowledgement. The answer remains durable; call await_reply again to reclaim it before acting."
28307
+ "The Paigy reply lease expired before acknowledgement. The answer remains durable; wait on it again (contact with only { wait: notificationId }) to reclaim it before acting."
28266
28308
  );
28267
28309
  this.name = "ReplyLeaseExpiredError";
28268
28310
  }
@@ -34886,13 +34928,20 @@ function contactSchemaFrom2(fields) {
34886
34928
  ),
34887
34929
  workId: fields.workId.describe(
34888
34930
  "The durable Work this contact advances. Pass the workId from check_replies or a prior reply when asking for a decision that blocks that work."
34931
+ ),
34932
+ // THE WAIT, CONTINUED (owner, 2026-09-06: "await should have been folded into contact").
34933
+ // A contact that rang holds its first window itself; the host caps one tool call at
34934
+ // ~60 s, so keeping the line is another contact — with ONLY this field. Nothing is sent.
34935
+ wait: external_exports.string().uuid().optional().describe(
34936
+ "KEEP WAITING on a live call: the notificationId a previous contact returned. Send it ALONE \u2014 no ask, nothing new goes to the user; contact just holds the next ~45 s window and returns the outcome in `wait`."
34889
34937
  )
34890
34938
  });
34891
34939
  const out = mcpInputSchema2(surface);
34892
- out.required = ["ask"];
34940
+ delete out.required;
34941
+ out.anyOf = [{ required: ["ask"] }, { required: ["wait"] }];
34893
34942
  return out;
34894
34943
  }
34895
- var CONTACT_DESCRIPTION2 = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId? } \u2014 pass notificationId to the reply path named by the returned \`message\`, and parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS2}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN2}-${OPTIONS_MAX2} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim.`;
34944
+ var CONTACT_DESCRIPTION2 = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId?, wait? }. WHEN IT RANG, contact holds the first ~45 s window ITSELF and \`wait\` carries the outcome: { type:'reply', answer } to act on; { type:'partial', inFlight:true, turn } \u2014 what the user is saying to each turn, provisional: use it to PREPARE (fetch, draft, warm the build), never to act irreversibly, they can still revise it until the final reply (partial = intelligence, settled = authorization; if a partial's acts carry a question aimed at you and you know the answer, contact on the SAME parentId right away \u2014 they hear it on the same call); { type:'remind', remindInSeconds } \u2014 schedule a wake-up; { type:'idle' } \u2014 still waiting. TO KEEP WAITING, call contact again with ONLY { wait: notificationId } \u2014 no ask, nothing new is sent; it holds the next scoped ~45 s window (under the 60 s host cap, so it always returns) and never returns another notification's reply. Keep doing that until the reply \u2014 THAT one is the decision \u2014 so the user steps away and comes back to find you already continued; stop only to do other work and check back, or after an unreasonably long stretch worth telling them about. A message delivery has NO \`wait\`: never poll for it \u2014 the reply arrives through check_replies or your wake. Pass parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS2}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN2}-${OPTIONS_MAX2} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim. READING A CALL'S REPLY: it can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a question that blocks me'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's parentId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry \`intents\` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' ("call me after lunch") \u2192 register it NOW with schedule_callback \u2014 when the intent carries \`dueInSeconds\` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' ("you pick") \u2192 make the call yourself and tell them what you chose; 'channel' ("text me next time") \u2192 honor it on your next contact (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped "should I call you back?" because the follow-up is implied. \`transcript\` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions ("yes, IF tests pass") before acting. If your ask declared \`points\`, the reply carries \`covered\` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (contact on the same parentId) or proceed knowingly partial; never treat a partial answer as complete.`;
34896
34945
  var ContextSchema2 = external_exports.object({
34897
34946
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
34898
34947
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -35263,11 +35312,21 @@ var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
35263
35312
  acts: external_exports.array(IntentSchema2).nullable().optional()
35264
35313
  })
35265
35314
  }),
35266
- external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema2).optional() })
35315
+ external_exports.object({
35316
+ type: external_exports.literal("idle"),
35317
+ also: external_exports.array(RideAlongSchema2).optional(),
35318
+ /** Is a call live for this agent's user right now? The SDK polls the partial stream
35319
+ * (#783) between idle ticks ONLY while this is not `false` — a partial can only exist
35320
+ * during a live call, and polling for one on a banner/message was a wasted HTTP call +
35321
+ * 3 queries on every idle tick of every waiting agent (~80% of all traffic at scale).
35322
+ * Absent = an older API → the SDK keeps polling, exactly as before. */
35323
+ inFlight: external_exports.boolean().optional()
35324
+ })
35267
35325
  ]);
35268
35326
  var CallbackTriggerSchema2 = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
35269
35327
  var ScheduleCallbackSchema2 = external_exports.object({
35270
35328
  parentId: external_exports.string().describe("The thread to call back on (from a prior contact / reply / request)."),
35329
+ goalId: external_exports.string().uuid().optional().describe("The Goal this callback advances; preferred for Goal-owned work."),
35271
35330
  trigger: CallbackTriggerSchema2,
35272
35331
  dueInSeconds: external_exports.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
35273
35332
  note: external_exports.string().optional().describe("What to tell the user when you follow up.")
@@ -35712,6 +35771,15 @@ var AgentActivitySchema2 = external_exports.object({
35712
35771
  var ConnectionSummarySchema2 = external_exports.object({
35713
35772
  /** The connection = the agent's token id (used to address a request). */
35714
35773
  id: external_exports.string(),
35774
+ /** The credential kind: "device" = a paired machine (mint-only — it hosts and mints, it
35775
+ * never talks); "agent" = an identity that sends. The roster and devices surfaces split
35776
+ * on this. Optional/absent reads as "agent" (a row predating the kind column). See
35777
+ * apps/api/src/tokens/devices-vs-agents-design.md. */
35778
+ kind: external_exports.enum(["device", "agent"]).optional(),
35779
+ /** For an agent, the token id of the DEVICE that minted it — so agents group under their
35780
+ * machine, and revoking a device cascades to them. Null on devices, and on unlinked
35781
+ * agents (phone-launched, provider-managed, or minted before the link existed). */
35782
+ mintedByDevice: external_exports.string().nullable().optional(),
35715
35783
  device: external_exports.string().nullable(),
35716
35784
  /** The agent's display name (the single pairing name). */
35717
35785
  name: external_exports.string(),
@@ -35751,6 +35819,22 @@ var ConnectionSummarySchema2 = external_exports.object({
35751
35819
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
35752
35820
  managed: external_exports.boolean()
35753
35821
  });
35822
+ var LedgerItemSchema2 = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
35823
+ var AgentLedgerSchema2 = external_exports.object({
35824
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
35825
+ /** Its own questions you have not answered. */
35826
+ asks: external_exports.array(LedgerItemSchema2),
35827
+ /** Its questions you answered that nobody acted on — still owed to somebody. */
35828
+ answered: external_exports.array(LedgerItemSchema2),
35829
+ /** Requests you sent it that it never took. */
35830
+ requests: external_exports.array(LedgerItemSchema2),
35831
+ goals: external_exports.array(external_exports.object({ id: external_exports.string(), outcome: external_exports.string(), state: external_exports.string() })),
35832
+ callbacks: external_exports.array(external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), trigger: external_exports.string(), note: external_exports.string(), dueAt: external_exports.string().nullable() }))
35833
+ });
35834
+ var ReassignResultSchema2 = external_exports.object({
35835
+ moved: external_exports.object({ asks: external_exports.number(), answered: external_exports.number(), requests: external_exports.number(), goals: external_exports.number(), callbacks: external_exports.number() }),
35836
+ parentId: external_exports.string().nullable()
35837
+ });
35754
35838
  var MoveRingSchema2 = external_exports.enum(["home", "travels", "retired", "quarantined"]);
35755
35839
  var MoveSchema2 = external_exports.object({
35756
35840
  id: external_exports.string(),
@@ -37134,6 +37218,25 @@ function bridgeLine(r) {
37134
37218
 
37135
37219
  // src/cli.ts
37136
37220
  var STATUS_MARK = { ready: "\u2713", login: "\u25D0", "adapter-missing": "\u25D0", missing: "\u2717" };
37221
+ function printConnectLines(link, userCode) {
37222
+ console.log("Connect this computer from your phone");
37223
+ console.log(` Open ${link}`);
37224
+ console.log(` or scan the QR above \xB7 or type ${userCode} in Paigy \u2192 Devices \u2192 Connect a computer`);
37225
+ }
37226
+ function forwardCodexThreadId() {
37227
+ const cfg = join5(process.env.CODEX_HOME || join5(homedir7(), ".codex"), "config.toml");
37228
+ if (!existsSync5(cfg)) return;
37229
+ const text = readFileSync4(cfg, "utf8");
37230
+ const head = "[mcp_servers.paigy]\n";
37231
+ const at = text.indexOf(head);
37232
+ if (at < 0) return;
37233
+ const block = text.slice(at + head.length, (() => {
37234
+ const n = text.indexOf("\n[", at + head.length);
37235
+ return n < 0 ? text.length : n;
37236
+ })());
37237
+ if (/^\s*env_vars\s*=/m.test(block)) return;
37238
+ writeFileSync4(cfg, text.slice(0, at + head.length) + 'env_vars = ["CODEX_THREAD_ID", "CODEX_SESSION_ID"]\n' + text.slice(at + head.length));
37239
+ }
37137
37240
  async function main() {
37138
37241
  const parsed = parseArgs(process.argv.slice(2));
37139
37242
  if ("error" in parsed) {
@@ -37209,8 +37312,7 @@ async function main() {
37209
37312
  if (!live) {
37210
37313
  const code = await requestCode(hostname().replace(/\.local$/, ""), void 0, hostname().replace(/\.local$/, ""));
37211
37314
  console.log(await import_qrcode.default.toString(code.verification_uri_complete, { type: "terminal", small: true }));
37212
- console.log(`Open: ${code.verification_uri_complete}`);
37213
- console.log(`Or scan the QR above, or enter code ${code.user_code} at ${code.verification_uri}`);
37315
+ printConnectLines(code.verification_uri_complete, code.user_code);
37214
37316
  const deadline = Date.now() + code.expires_in * 1e3;
37215
37317
  let paired = false;
37216
37318
  while (!paired && Date.now() < deadline) {
@@ -37250,15 +37352,21 @@ async function main() {
37250
37352
  } else console.log("start hosting with: paigy-harness host (keep it running under your init system)");
37251
37353
  const TERMINAL_AGENTS = [
37252
37354
  { cli: "claude", name: "Claude Code", wire: "claude plugin marketplace add paigy-ai/mcp; claude plugin install paigy@paigy" },
37253
- { cli: "codex", name: "Codex", wire: `codex mcp add paigy -c 'mcp_servers.paigy.env_vars=["CODEX_THREAD_ID","CODEX_SESSION_ID"]' -- npx -y @paigy/mcp@latest` },
37355
+ // Plain add only. Forwarding CODEX_THREAD_ID is a SECOND step (forwardCodexThreadId):
37356
+ // a `-c mcp_servers.paigy.env_vars=…` override on `mcp add` lands before the block is
37357
+ // written, codex sees a half-table and rejects it ("invalid transport"), and nothing at
37358
+ // all gets wired — which the old silent catch below hid entirely (found by QA 2026-09-06).
37359
+ { cli: "codex", name: "Codex", wire: "codex mcp add paigy -- npx -y @paigy/mcp@latest" },
37254
37360
  { cli: "agy", name: "Antigravity" }
37255
37361
  ];
37256
37362
  for (const t of TERMINAL_AGENTS) {
37257
37363
  if (!which(t.cli) || !t.wire) continue;
37258
37364
  try {
37259
37365
  execSync(t.wire, { stdio: "ignore", shell: "/bin/sh" });
37366
+ if (t.cli === "codex") forwardCodexThreadId();
37260
37367
  console.log(`\u2713 paigy wired for ${t.cli} \u2014 each session hatches its own identity (restart open sessions)`);
37261
- } catch {
37368
+ } catch (e) {
37369
+ console.log(`\u26A0 could not wire ${t.cli} (${e.message.split("\n")[0]}) \u2014 the harness still works; wire it by hand later`);
37262
37370
  }
37263
37371
  }
37264
37372
  if (which("claude")) {
@@ -37301,8 +37409,7 @@ async function main() {
37301
37409
  if (existing) console.log("\u26A0 this machine was logged out \u2014 reconnecting it now");
37302
37410
  const code = await requestCode(hostname().replace(/\.local$/, ""), void 0, hostname().replace(/\.local$/, ""));
37303
37411
  console.log(await import_qrcode.default.toString(code.verification_uri_complete, { type: "terminal", small: true }));
37304
- console.log(`Open: ${code.verification_uri_complete}`);
37305
- console.log(`Or scan the QR above, or enter code ${code.user_code} at ${code.verification_uri}`);
37412
+ printConnectLines(code.verification_uri_complete, code.user_code);
37306
37413
  const deadline = Date.now() + code.expires_in * 1e3;
37307
37414
  while (Date.now() < deadline) {
37308
37415
  const token = await pollToken(code.device_code).catch(() => null);
package/dist/main.js CHANGED
@@ -12207,13 +12207,20 @@ function contactSchemaFrom(fields) {
12207
12207
  ),
12208
12208
  workId: fields.workId.describe(
12209
12209
  "The durable Work this contact advances. Pass the workId from check_replies or a prior reply when asking for a decision that blocks that work."
12210
+ ),
12211
+ // THE WAIT, CONTINUED (owner, 2026-09-06: "await should have been folded into contact").
12212
+ // A contact that rang holds its first window itself; the host caps one tool call at
12213
+ // ~60 s, so keeping the line is another contact — with ONLY this field. Nothing is sent.
12214
+ wait: external_exports.string().uuid().optional().describe(
12215
+ "KEEP WAITING on a live call: the notificationId a previous contact returned. Send it ALONE \u2014 no ask, nothing new goes to the user; contact just holds the next ~45 s window and returns the outcome in `wait`."
12210
12216
  )
12211
12217
  });
12212
12218
  const out = mcpInputSchema(surface);
12213
- out.required = ["ask"];
12219
+ delete out.required;
12220
+ out.anyOf = [{ required: ["ask"] }, { required: ["wait"] }];
12214
12221
  return out;
12215
12222
  }
12216
- var CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId? } \u2014 pass notificationId to the reply path named by the returned \`message\`, and parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim.`;
12223
+ var CONTACT_DESCRIPTION = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId?, wait? }. WHEN IT RANG, contact holds the first ~45 s window ITSELF and \`wait\` carries the outcome: { type:'reply', answer } to act on; { type:'partial', inFlight:true, turn } \u2014 what the user is saying to each turn, provisional: use it to PREPARE (fetch, draft, warm the build), never to act irreversibly, they can still revise it until the final reply (partial = intelligence, settled = authorization; if a partial's acts carry a question aimed at you and you know the answer, contact on the SAME parentId right away \u2014 they hear it on the same call); { type:'remind', remindInSeconds } \u2014 schedule a wake-up; { type:'idle' } \u2014 still waiting. TO KEEP WAITING, call contact again with ONLY { wait: notificationId } \u2014 no ask, nothing new is sent; it holds the next scoped ~45 s window (under the 60 s host cap, so it always returns) and never returns another notification's reply. Keep doing that until the reply \u2014 THAT one is the decision \u2014 so the user steps away and comes back to find you already continued; stop only to do other work and check back, or after an unreasonably long stretch worth telling them about. A message delivery has NO \`wait\`: never poll for it \u2014 the reply arrives through check_replies or your wake. Pass parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN}-${OPTIONS_MAX} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim. READING A CALL'S REPLY: it can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a question that blocks me'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's parentId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry \`intents\` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' ("call me after lunch") \u2192 register it NOW with schedule_callback \u2014 when the intent carries \`dueInSeconds\` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' ("you pick") \u2192 make the call yourself and tell them what you chose; 'channel' ("text me next time") \u2192 honor it on your next contact (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped "should I call you back?" because the follow-up is implied. \`transcript\` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions ("yes, IF tests pass") before acting. If your ask declared \`points\`, the reply carries \`covered\` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (contact on the same parentId) or proceed knowingly partial; never treat a partial answer as complete.`;
12217
12224
  var ContextSchema = external_exports.object({
12218
12225
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
12219
12226
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -12622,11 +12629,21 @@ var AwaitItemSchema = external_exports.discriminatedUnion("type", [
12622
12629
  acts: external_exports.array(IntentSchema).nullable().optional()
12623
12630
  })
12624
12631
  }),
12625
- external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema).optional() })
12632
+ external_exports.object({
12633
+ type: external_exports.literal("idle"),
12634
+ also: external_exports.array(RideAlongSchema).optional(),
12635
+ /** Is a call live for this agent's user right now? The SDK polls the partial stream
12636
+ * (#783) between idle ticks ONLY while this is not `false` — a partial can only exist
12637
+ * during a live call, and polling for one on a banner/message was a wasted HTTP call +
12638
+ * 3 queries on every idle tick of every waiting agent (~80% of all traffic at scale).
12639
+ * Absent = an older API → the SDK keeps polling, exactly as before. */
12640
+ inFlight: external_exports.boolean().optional()
12641
+ })
12626
12642
  ]);
12627
12643
  var CallbackTriggerSchema = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
12628
12644
  var ScheduleCallbackSchema = external_exports.object({
12629
12645
  parentId: external_exports.string().describe("The thread to call back on (from a prior contact / reply / request)."),
12646
+ goalId: external_exports.string().uuid().optional().describe("The Goal this callback advances; preferred for Goal-owned work."),
12630
12647
  trigger: CallbackTriggerSchema,
12631
12648
  dueInSeconds: external_exports.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
12632
12649
  note: external_exports.string().optional().describe("What to tell the user when you follow up.")
@@ -13071,6 +13088,15 @@ var AgentActivitySchema = external_exports.object({
13071
13088
  var ConnectionSummarySchema = external_exports.object({
13072
13089
  /** The connection = the agent's token id (used to address a request). */
13073
13090
  id: external_exports.string(),
13091
+ /** The credential kind: "device" = a paired machine (mint-only — it hosts and mints, it
13092
+ * never talks); "agent" = an identity that sends. The roster and devices surfaces split
13093
+ * on this. Optional/absent reads as "agent" (a row predating the kind column). See
13094
+ * apps/api/src/tokens/devices-vs-agents-design.md. */
13095
+ kind: external_exports.enum(["device", "agent"]).optional(),
13096
+ /** For an agent, the token id of the DEVICE that minted it — so agents group under their
13097
+ * machine, and revoking a device cascades to them. Null on devices, and on unlinked
13098
+ * agents (phone-launched, provider-managed, or minted before the link existed). */
13099
+ mintedByDevice: external_exports.string().nullable().optional(),
13074
13100
  device: external_exports.string().nullable(),
13075
13101
  /** The agent's display name (the single pairing name). */
13076
13102
  name: external_exports.string(),
@@ -13110,6 +13136,22 @@ var ConnectionSummarySchema = external_exports.object({
13110
13136
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
13111
13137
  managed: external_exports.boolean()
13112
13138
  });
13139
+ var LedgerItemSchema = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
13140
+ var AgentLedgerSchema = external_exports.object({
13141
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
13142
+ /** Its own questions you have not answered. */
13143
+ asks: external_exports.array(LedgerItemSchema),
13144
+ /** Its questions you answered that nobody acted on — still owed to somebody. */
13145
+ answered: external_exports.array(LedgerItemSchema),
13146
+ /** Requests you sent it that it never took. */
13147
+ requests: external_exports.array(LedgerItemSchema),
13148
+ goals: external_exports.array(external_exports.object({ id: external_exports.string(), outcome: external_exports.string(), state: external_exports.string() })),
13149
+ callbacks: external_exports.array(external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), trigger: external_exports.string(), note: external_exports.string(), dueAt: external_exports.string().nullable() }))
13150
+ });
13151
+ var ReassignResultSchema = external_exports.object({
13152
+ moved: external_exports.object({ asks: external_exports.number(), answered: external_exports.number(), requests: external_exports.number(), goals: external_exports.number(), callbacks: external_exports.number() }),
13153
+ parentId: external_exports.string().nullable()
13154
+ });
13113
13155
  var MoveRingSchema = external_exports.enum(["home", "travels", "retired", "quarantined"]);
13114
13156
  var MoveSchema = external_exports.object({
13115
13157
  id: external_exports.string(),
@@ -14010,7 +14052,7 @@ var sleep3 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
14010
14052
  var ReplyLeaseExpiredError = class extends Error {
14011
14053
  constructor() {
14012
14054
  super(
14013
- "The Paigy reply lease expired before acknowledgement. The answer remains durable; call await_reply again to reclaim it before acting."
14055
+ "The Paigy reply lease expired before acknowledgement. The answer remains durable; wait on it again (contact with only { wait: notificationId }) to reclaim it before acting."
14014
14056
  );
14015
14057
  this.name = "ReplyLeaseExpiredError";
14016
14058
  }
@@ -15656,13 +15698,20 @@ function contactSchemaFrom2(fields) {
15656
15698
  ),
15657
15699
  workId: fields.workId.describe(
15658
15700
  "The durable Work this contact advances. Pass the workId from check_replies or a prior reply when asking for a decision that blocks that work."
15701
+ ),
15702
+ // THE WAIT, CONTINUED (owner, 2026-09-06: "await should have been folded into contact").
15703
+ // A contact that rang holds its first window itself; the host caps one tool call at
15704
+ // ~60 s, so keeping the line is another contact — with ONLY this field. Nothing is sent.
15705
+ wait: external_exports.string().uuid().optional().describe(
15706
+ "KEEP WAITING on a live call: the notificationId a previous contact returned. Send it ALONE \u2014 no ask, nothing new goes to the user; contact just holds the next ~45 s window and returns the outcome in `wait`."
15659
15707
  )
15660
15708
  });
15661
15709
  const out = mcpInputSchema2(surface);
15662
- out.required = ["ask"];
15710
+ delete out.required;
15711
+ out.anyOf = [{ required: ["ask"] }, { required: ["wait"] }];
15663
15712
  return out;
15664
15713
  }
15665
- var CONTACT_DESCRIPTION2 = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId? } \u2014 pass notificationId to the reply path named by the returned \`message\`, and parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS2}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN2}-${OPTIONS_MAX2} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim.`;
15714
+ var CONTACT_DESCRIPTION2 = `Reach the user through Paigy \u2014 tell them something, or ask and get their answer. State what you need in \`ask\`, say what happens to your work while you wait in \`waiting\`, and Paigy handles the rest (channel, phrasing, answer format). If the user explicitly asks you to CALL them, send waiting:'hard' and say so in the ask. Returns { notificationId, parentId, workId?, decisionId?, wait? }. WHEN IT RANG, contact holds the first ~45 s window ITSELF and \`wait\` carries the outcome: { type:'reply', answer } to act on; { type:'partial', inFlight:true, turn } \u2014 what the user is saying to each turn, provisional: use it to PREPARE (fetch, draft, warm the build), never to act irreversibly, they can still revise it until the final reply (partial = intelligence, settled = authorization; if a partial's acts carry a question aimed at you and you know the answer, contact on the SAME parentId right away \u2014 they hear it on the same call); { type:'remind', remindInSeconds } \u2014 schedule a wake-up; { type:'idle' } \u2014 still waiting. TO KEEP WAITING, call contact again with ONLY { wait: notificationId } \u2014 no ask, nothing new is sent; it holds the next scoped ~45 s window (under the 60 s host cap, so it always returns) and never returns another notification's reply. Keep doing that until the reply \u2014 THAT one is the decision \u2014 so the user steps away and comes back to find you already continued; stop only to do other work and check back, or after an unreasonably long stretch worth telling them about. A message delivery has NO \`wait\`: never poll for it \u2014 the reply arrives through check_replies or your wake. Pass parentId to a later contact to continue the conversation. When it rang, the reply also carries { ifMissed: { mode, means } }: what the user's own policy does with a call they don't take ("${STANDARD_MEANS2}"), so a no-answer tells you how long to wait before coming back. THREADING REPLACES: a threaded follow-up SUPERSEDES your earlier pending items on that thread \u2014 right for updates to one ask, WRONG for a checklist (send independent to-dos un-threaded). A threaded re-send with IDENTICAL content escalates the pending ask in place. If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail \u2014 contact again on the SAME parentId with an expanded ask. BLOCKED ON A DECISION for existing work? Pass that work's \`workId\`; the reply returns the same workId plus a decisionId, so the answer resumes the right outcome. ONE ASK, ONE ROW: never restate a still-pending ask's question inside a NEW contact (e.g. weaving it into a briefing) \u2014 the whole answer settles on the new row and the original can never receive it. Keep waiting on the original (a live call reads every pending ask out separately, each answer routes to its own row), and use \`needs\` for a genuinely multi-part NEW ask. ANSWERABLE, NOT JUST ASKED: when the reply comes back carrying \`plan.units[].needs\`, that unit asked for something it gave the user no way to answer \u2014 'options' means it posed a choice with nothing to choose from, 'visuals' means it asked about something to look at with nothing to look at. Send it again on the SAME parentId with ${OPTIONS_MIN2}-${OPTIONS_MAX2} options (or the image), drawn from your own sentence. Paigy will not add them for you: a shape it guessed wrong cannot be undone, and you are the one who knows what the real alternatives are. \`units\` reports WHAT BECAME OF YOUR PROSE \u2014 { kept, raw, why }: how many topics Paigy compressed for delivery, how many kept your exact words, and the reason when it kept them (e.g. 'no_output' = compression produced nothing usable, so the user got your raw sentence). It needs no action and is not an error \u2014 read it only when the delivered wording matters to you; a high \`raw\` count means the user is hearing you verbatim. READING A CALL'S REPLY: it can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a question that blocks me'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's parentId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry \`intents\` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' ("call me after lunch") \u2192 register it NOW with schedule_callback \u2014 when the intent carries \`dueInSeconds\` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' ("you pick") \u2192 make the call yourself and tell them what you chose; 'channel' ("text me next time") \u2192 honor it on your next contact (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped "should I call you back?" because the follow-up is implied. \`transcript\` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions ("yes, IF tests pass") before acting. If your ask declared \`points\`, the reply carries \`covered\` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (contact on the same parentId) or proceed knowingly partial; never treat a partial answer as complete.`;
15666
15715
  var ContextSchema2 = external_exports.object({
15667
15716
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
15668
15717
  description: external_exports.array(external_exports.string().min(1)).describe(
@@ -16033,11 +16082,21 @@ var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
16033
16082
  acts: external_exports.array(IntentSchema2).nullable().optional()
16034
16083
  })
16035
16084
  }),
16036
- external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema2).optional() })
16085
+ external_exports.object({
16086
+ type: external_exports.literal("idle"),
16087
+ also: external_exports.array(RideAlongSchema2).optional(),
16088
+ /** Is a call live for this agent's user right now? The SDK polls the partial stream
16089
+ * (#783) between idle ticks ONLY while this is not `false` — a partial can only exist
16090
+ * during a live call, and polling for one on a banner/message was a wasted HTTP call +
16091
+ * 3 queries on every idle tick of every waiting agent (~80% of all traffic at scale).
16092
+ * Absent = an older API → the SDK keeps polling, exactly as before. */
16093
+ inFlight: external_exports.boolean().optional()
16094
+ })
16037
16095
  ]);
16038
16096
  var CallbackTriggerSchema2 = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
16039
16097
  var ScheduleCallbackSchema2 = external_exports.object({
16040
16098
  parentId: external_exports.string().describe("The thread to call back on (from a prior contact / reply / request)."),
16099
+ goalId: external_exports.string().uuid().optional().describe("The Goal this callback advances; preferred for Goal-owned work."),
16041
16100
  trigger: CallbackTriggerSchema2,
16042
16101
  dueInSeconds: external_exports.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
16043
16102
  note: external_exports.string().optional().describe("What to tell the user when you follow up.")
@@ -16482,6 +16541,15 @@ var AgentActivitySchema2 = external_exports.object({
16482
16541
  var ConnectionSummarySchema2 = external_exports.object({
16483
16542
  /** The connection = the agent's token id (used to address a request). */
16484
16543
  id: external_exports.string(),
16544
+ /** The credential kind: "device" = a paired machine (mint-only — it hosts and mints, it
16545
+ * never talks); "agent" = an identity that sends. The roster and devices surfaces split
16546
+ * on this. Optional/absent reads as "agent" (a row predating the kind column). See
16547
+ * apps/api/src/tokens/devices-vs-agents-design.md. */
16548
+ kind: external_exports.enum(["device", "agent"]).optional(),
16549
+ /** For an agent, the token id of the DEVICE that minted it — so agents group under their
16550
+ * machine, and revoking a device cascades to them. Null on devices, and on unlinked
16551
+ * agents (phone-launched, provider-managed, or minted before the link existed). */
16552
+ mintedByDevice: external_exports.string().nullable().optional(),
16485
16553
  device: external_exports.string().nullable(),
16486
16554
  /** The agent's display name (the single pairing name). */
16487
16555
  name: external_exports.string(),
@@ -16521,6 +16589,22 @@ var ConnectionSummarySchema2 = external_exports.object({
16521
16589
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
16522
16590
  managed: external_exports.boolean()
16523
16591
  });
16592
+ var LedgerItemSchema2 = external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), title: external_exports.string(), createdAt: external_exports.string() });
16593
+ var AgentLedgerSchema2 = external_exports.object({
16594
+ agent: external_exports.object({ id: external_exports.string(), name: external_exports.string(), revokedAt: external_exports.string().nullable() }),
16595
+ /** Its own questions you have not answered. */
16596
+ asks: external_exports.array(LedgerItemSchema2),
16597
+ /** Its questions you answered that nobody acted on — still owed to somebody. */
16598
+ answered: external_exports.array(LedgerItemSchema2),
16599
+ /** Requests you sent it that it never took. */
16600
+ requests: external_exports.array(LedgerItemSchema2),
16601
+ goals: external_exports.array(external_exports.object({ id: external_exports.string(), outcome: external_exports.string(), state: external_exports.string() })),
16602
+ callbacks: external_exports.array(external_exports.object({ id: external_exports.string(), parentId: external_exports.string(), trigger: external_exports.string(), note: external_exports.string(), dueAt: external_exports.string().nullable() }))
16603
+ });
16604
+ var ReassignResultSchema2 = external_exports.object({
16605
+ moved: external_exports.object({ asks: external_exports.number(), answered: external_exports.number(), requests: external_exports.number(), goals: external_exports.number(), callbacks: external_exports.number() }),
16606
+ parentId: external_exports.string().nullable()
16607
+ });
16524
16608
  var MoveRingSchema2 = external_exports.enum(["home", "travels", "retired", "quarantined"]);
16525
16609
  var MoveSchema2 = external_exports.object({
16526
16610
  id: external_exports.string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paigy/harness",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Run Claude Code / Codex on this machine, bridged to Paigy — launchable from your phone.",
5
5
  "license": "MIT",
6
6
  "type": "module",