@paigy/harness 0.3.0 → 0.3.2

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 +454 -86
  2. package/dist/main.js +833 -507
  3. package/package.json +13 -12
package/dist/main.js CHANGED
@@ -10959,6 +10959,12 @@ var ReceiptEventSchema = external_exports.enum([
10959
10959
  // the recipient opened it
10960
10960
  "answered",
10961
10961
  // the recipient replied
10962
+ // The recipient TURNED THE RING DOWN — CallKit ended it and no answer was ever tapped.
10963
+ // Written by the phone, on the same door that reports the ring itself, so it exists only
10964
+ // when a ring reached a running app and a person did not take it. That is what separates
10965
+ // it from "a ring with no answer", which our own crashes wrote just as readily and which
10966
+ // is why the responsiveness back-off had to be removed (#1144).
10967
+ "declined",
10962
10968
  "escalated",
10963
10969
  // re-reached at a higher level (re-ring / promote)
10964
10970
  "coalesced",
@@ -10967,8 +10973,14 @@ var ReceiptEventSchema = external_exports.enum([
10967
10973
  // deadline passed unanswered
10968
10974
  "woke",
10969
10975
  // the agent was woken for an owed obligation (callback)
10970
- "gave_up"
10976
+ "gave_up",
10971
10977
  // the budget was spent — stopped re-engaging
10978
+ // The ladder starts over — a silent pickup (the owner's fresh-miss rule, 2026-07-28), a
10979
+ // promote to call, a re-delivery. APPENDED, never a rewind: `ring_step` was a cache of
10980
+ // the escalated-count and a writer rewound it to 0 on every unanswered call (2026-08-24,
10981
+ // nine rings in an hour, `gaveUp` unreachable). A count since the last `restarted` cannot
10982
+ // be rewound by a writer that forgot to advance it.
10983
+ "restarted"
10972
10984
  ]);
10973
10985
  var AttentionSchema = external_exports.object({
10974
10986
  urgency: NotifyLevelSchema,
@@ -11100,7 +11112,7 @@ var NotifyRequestSchema = external_exports.object({
11100
11112
  if (r.ask !== void 0) {
11101
11113
  for (const f of ["context", "select", "points"]) {
11102
11114
  if (r[f] !== void 0)
11103
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it (use \`needs\` for multi-part asks)` });
11115
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives the answer shape from your prose. Drop ${f} and say it in \`ask\` instead ("should I\u2026" for approve/deny, "which of these\u2026" for a pick), passing \`options\` when you're offering concrete alternatives.` });
11104
11116
  }
11105
11117
  return;
11106
11118
  }
@@ -11213,6 +11225,14 @@ var IntentSchema = external_exports.object({
11213
11225
  * about what cannot answer "is the bot looping less this week?". */
11214
11226
  fault: external_exports.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
11215
11227
  });
11228
+ var RideAlongSchema = external_exports.object({
11229
+ /** The note this came from — assign/clarify/close it through /api/notes/:id. */
11230
+ noteId: external_exports.string(),
11231
+ /** What to do, in the owner's own words (the note's headline). Never model-rewritten. */
11232
+ text: external_exports.string(),
11233
+ /** The thread to report back on, when the note was dispatched over the request rail. */
11234
+ parentId: external_exports.string().nullable()
11235
+ });
11216
11236
  var AwaitItemSchema = external_exports.discriminatedUnion("type", [
11217
11237
  external_exports.object({
11218
11238
  type: external_exports.literal("reply"),
@@ -11233,7 +11253,13 @@ var AwaitItemSchema = external_exports.discriminatedUnion("type", [
11233
11253
  transcript: external_exports.string().optional(),
11234
11254
  /** Coverage report (#396), when the ask declared `points`: which of them this
11235
11255
  * answer addressed. Missing points = re-ask or proceed knowingly partial. */
11236
- covered: external_exports.array(external_exports.string()).optional()
11256
+ covered: external_exports.array(external_exports.string()).optional(),
11257
+ /** Ride-alongs (RideAlongSchema) — pending work for you, attached to the moment you
11258
+ * became free. Only `reply` and `idle` carry it: those are the two outcomes that
11259
+ * END a wait. `remind`, `superseded` and `turn` are mid-flight, and handing an
11260
+ * agent a side-quest while it is still holding the line is how the main thing gets
11261
+ * dropped. Absent/empty = nothing owed. */
11262
+ also: external_exports.array(RideAlongSchema).optional()
11237
11263
  }),
11238
11264
  external_exports.object({
11239
11265
  type: external_exports.literal("remind"),
@@ -11268,7 +11294,7 @@ var AwaitItemSchema = external_exports.discriminatedUnion("type", [
11268
11294
  acts: external_exports.array(IntentSchema).nullable().optional()
11269
11295
  })
11270
11296
  }),
11271
- external_exports.object({ type: external_exports.literal("idle") })
11297
+ external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema).optional() })
11272
11298
  ]);
11273
11299
  var CallbackTriggerSchema = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
11274
11300
  var ScheduleCallbackSchema = external_exports.object({
@@ -11299,6 +11325,12 @@ var PendingRepliesSchema = external_exports.object({
11299
11325
  pending: external_exports.array(
11300
11326
  external_exports.object({ parentId: external_exports.string(), notificationId: external_exports.string(), createdAt: external_exports.string() })
11301
11327
  ),
11328
+ /** WHO YOU ARE on this account (field report 2026-08-28): the name and device the user
11329
+ * sees for this session's identity. From inside a session there was no way to find out —
11330
+ * `pair` with no arguments can HATCH a fresh identity, so it is not a safe probe — and an
11331
+ * agent that cannot tell which agent it is cannot tell whether work addressed to
11332
+ * "Reta" was addressed to it. Absent only for a token with no pairing behind it. */
11333
+ you: external_exports.object({ name: external_exports.string(), device: external_exports.string().nullable(), tokenId: external_exports.string() }).optional(),
11302
11334
  /** User-initiated requests addressed to this agent; act on them and reply via
11303
11335
  * contact on the same parentId. Keeps reappearing until you call
11304
11336
  * set_task_state on its notificationId. */
@@ -11310,12 +11342,21 @@ var PendingRepliesSchema = external_exports.object({
11310
11342
  createdAt: external_exports.string(),
11311
11343
  /** The user seeded this request with a past conversation — call get_thread on it
11312
11344
  * FIRST and treat the transcript as prior context (#57/#251). */
11313
- contextParentId: external_exports.string().optional()
11345
+ contextParentId: external_exports.string().optional(),
11346
+ /** STRANDED (field report 2026-08-28): this request was addressed to ANOTHER agent on
11347
+ * the account — the name here — which has not been seen since it landed, so nobody
11348
+ * came for it. Handed to you because you are the session that is here. Take it like
11349
+ * any request (set_task_state claims it, reply with contact on its parentId), and say
11350
+ * whose it was, because the user chose that agent on purpose. */
11351
+ stranded: external_exports.string().optional()
11314
11352
  })
11315
11353
  ),
11316
11354
  /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
11317
11355
  * if blocked, or at a time that has passed). Re-surfaced every sweep until you
11318
11356
  * fulfill one by calling contact on its parentId. */
11357
+ /** Ride-alongs (RideAlongSchema): notes assigned to this agent that no wake could
11358
+ * reach. Same array the contact/await replies carry — one queue, every carrier. */
11359
+ also: external_exports.array(RideAlongSchema).optional(),
11319
11360
  owedCallbacks: external_exports.array(
11320
11361
  external_exports.object({ parentId: external_exports.string(), trigger: CallbackTriggerSchema, note: external_exports.string() })
11321
11362
  ),
@@ -11351,7 +11392,11 @@ var NotifyResponseSchema = external_exports.object({
11351
11392
  status: NotifyStatusSchema,
11352
11393
  createdAt: external_exports.string().datetime(),
11353
11394
  answer: UserAnswerSchema.optional(),
11354
- answeredAt: external_exports.string().datetime().optional()
11395
+ answeredAt: external_exports.string().datetime().optional(),
11396
+ /** Ride-alongs for THIS agent — pending work it should pick up when it's done with
11397
+ * what it came for. Present on any reply, because an unwakeable agent's only
11398
+ * reliable moment is one it initiated. Absent/empty = nothing owed. */
11399
+ also: external_exports.array(RideAlongSchema).optional()
11355
11400
  });
11356
11401
  var NotifyPlanUnitSchema = external_exports.object({
11357
11402
  notificationId: external_exports.string(),
@@ -11373,7 +11418,13 @@ var NotifyPlanUnitSchema = external_exports.object({
11373
11418
  proposal: external_exports.object({
11374
11419
  select: SelectShapeSchema,
11375
11420
  options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
11376
- }).optional()
11421
+ }).optional(),
11422
+ /** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
11423
+ * between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
11424
+ * needs to know. Reported so the agent can correct a misread the same way it ratifies a
11425
+ * shape — the read RAISES (a decision always asks) and never silences a question the
11426
+ * agent declared (#731, #923). */
11427
+ wants: external_exports.enum(["decision", "approval", "knowledge"]).optional()
11377
11428
  });
11378
11429
  var NotifyPlanSchema = external_exports.object({
11379
11430
  units: external_exports.array(NotifyPlanUnitSchema),
@@ -11397,6 +11448,9 @@ var UserResponseSchema = external_exports.object({
11397
11448
  });
11398
11449
  var VoiceKeySchema = external_exports.enum(["rachel", "george", "jessica", "brian", "lily"]);
11399
11450
  var AgendaTurnSchema = external_exports.object({
11451
+ /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
11452
+ * the planner declares duplicates instead of asking them twice. */
11453
+ coveredIds: external_exports.array(external_exports.string()).optional(),
11400
11454
  /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
11401
11455
  * line went out on 2026-07-28 and the caller could not answer it at all. */
11402
11456
  info: external_exports.array(external_exports.string().min(1)).max(3).default([]),
@@ -11430,6 +11484,7 @@ var AgendaTurnSchema = external_exports.object({
11430
11484
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
11431
11485
  blocking: external_exports.boolean().optional()
11432
11486
  });
11487
+ var CLAIM_STALE_MS = 30 * 6e4;
11433
11488
  var InboxItemSchema = external_exports.object({
11434
11489
  id: external_exports.string(),
11435
11490
  /** The conversation thread + connection this item lives on. Present on the replied
@@ -11449,6 +11504,15 @@ var InboxItemSchema = external_exports.object({
11449
11504
  * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
11450
11505
  * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
11451
11506
  seq: external_exports.number().int().optional(),
11507
+ /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
11508
+ * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
11509
+ * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
11510
+ * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
11511
+ * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
11512
+ * server that can still see every row states it. Absent on any row with no `askId`: a
11513
+ * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
11514
+ * the one part of its own arrival a single row cannot answer. */
11515
+ units: external_exports.number().int().positive().optional(),
11452
11516
  tokenId: external_exports.string().optional(),
11453
11517
  status: NotifyStatusSchema,
11454
11518
  context: ContextSchema,
@@ -11469,6 +11533,16 @@ var InboxItemSchema = external_exports.object({
11469
11533
  * while the party called the same dead claim stalled. Absent = no token/no data,
11470
11534
  * which must never CLAIM stalled. */
11471
11535
  lastSeenAt: external_exports.string().optional(),
11536
+ /** WHEN THE AGENT LAST SAID ANYTHING ABOUT THIS CLAIM — the newest `agent_state` row in
11537
+ * the `notification_events` ledger (trigger-written since 20260621010000, so every row a
11538
+ * user can see has one). The age input for `CLAIM_STALE_MS`, and it has to be this rather
11539
+ * than `createdAt`: a claim is very often picked up long after the row was born — the
11540
+ * inbox keeps an ANSWERED row visible while the agent works the follow-up, so a question
11541
+ * asked this morning and claimed a minute ago is eight hours old and one minute into its
11542
+ * work. Reading the row's birth as the claim's age brands that "No update in 8h" the
11543
+ * instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
11544
+ * `createdAt`. */
11545
+ agentStateAt: external_exports.string().datetime().optional(),
11472
11546
  agenda: external_exports.array(AgendaTurnSchema).optional(),
11473
11547
  /** On a replied detail (#397): the next steps the user attached to the answer
11474
11548
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -11540,11 +11614,23 @@ var SnoozeRequestSchema = external_exports.object({
11540
11614
  requestId: external_exports.string(),
11541
11615
  until: external_exports.string().datetime()
11542
11616
  });
11617
+ var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
11543
11618
  var PushTokenSchema = external_exports.object({
11544
11619
  voipToken: external_exports.string().min(1).optional(),
11545
11620
  alertToken: external_exports.string().min(1).optional(),
11546
11621
  fcmToken: external_exports.string().min(1).optional(),
11547
11622
  platform: external_exports.enum(["ios", "android"])
11623
+ }).superRefine((v, ctx) => {
11624
+ if (v.platform !== "ios") return;
11625
+ for (const field of ["voipToken", "alertToken"]) {
11626
+ const token = v[field];
11627
+ if (token === void 0 || APNS_TOKEN_RE.test(token)) continue;
11628
+ ctx.addIssue({
11629
+ code: external_exports.ZodIssueCode.custom,
11630
+ path: [field],
11631
+ message: `not an APNs device token (want 64 hex chars, got ${token.length})`
11632
+ });
11633
+ }
11548
11634
  });
11549
11635
  var MissedCallSchema = external_exports.enum([
11550
11636
  "retry_10m",
@@ -11595,6 +11681,15 @@ var UserSettingsSchema = external_exports.object({
11595
11681
  /** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
11596
11682
  * defaulted — an older client PATCHing the full object must not clobber it. */
11597
11683
  pstnCalls: external_exports.boolean().optional(),
11684
+ /** The user's IANA timezone (e.g. "America/Bogota"), recorded by the app — it is the
11685
+ * only party that knows it. REMINDERS are why it exists: "remind me at ten" becomes
11686
+ * an absolute `due_at` only if we know whose ten. Optional and never defaulted, for
11687
+ * the same reason `voiceMode` is (a stale client PATCHing the whole object must not
11688
+ * clobber it) and one more: a GUESSED timezone schedules reminders hours off, and
11689
+ * that failure reads as the reminder rail being unreliable rather than as a missing
11690
+ * setting. Absent = a spoken time can't be landed, so the reminder rides the next
11691
+ * call — honest about what we know. */
11692
+ timezone: external_exports.string().min(1).max(64).optional(),
11598
11693
  /** Account E2EE state (text lane): 'off' (default) = today's plaintext; 'on' =
11599
11694
  * content is sealed end-to-end between the local agent and the phone. Like
11600
11695
  * voiceMode, OPTIONAL and NOT defaulted so a stale client PATCHing the full
@@ -11620,6 +11715,15 @@ var HistoryItemSchema = external_exports.object({
11620
11715
  /** When you answered the agent's notification (agent→user only). */
11621
11716
  humanAckedAt: external_exports.string().nullable()
11622
11717
  });
11718
+ var ACTIVITY_LINES = 2;
11719
+ var ACTIVITY_LINE_MAX = 80;
11720
+ var AgentActivitySchema = external_exports.object({
11721
+ /** Oldest first, so the newest line is last — the one that replaces in place. */
11722
+ lines: external_exports.array(external_exports.string().max(ACTIVITY_LINE_MAX)).max(ACTIVITY_LINES),
11723
+ /** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
11724
+ * that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
11725
+ at: external_exports.string().datetime()
11726
+ });
11623
11727
  var ConnectionSummarySchema = external_exports.object({
11624
11728
  /** The connection = the agent's token id (used to address a request). */
11625
11729
  id: external_exports.string(),
@@ -11653,6 +11757,11 @@ var ConnectionSummarySchema = external_exports.object({
11653
11757
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
11654
11758
  workspaces: external_exports.array(external_exports.string()).optional()
11655
11759
  }).optional(),
11760
+ /** The tail of this agent's working log, when a harness is driving it — the agent page's
11761
+ * live strip. Absent for anything the desktop harness isn't running (a hatched identity
11762
+ * used straight from a terminal emits no work events; the page says so rather than
11763
+ * drawing an empty box). */
11764
+ activity: AgentActivitySchema.optional(),
11656
11765
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
11657
11766
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
11658
11767
  managed: external_exports.boolean()
@@ -11721,7 +11830,8 @@ var HandoffSchema = external_exports.object({
11721
11830
  recap: external_exports.boolean().optional()
11722
11831
  });
11723
11832
  var NoteSourceSchema = external_exports.enum(["app", "call"]);
11724
- var NoteStatusSchema = external_exports.enum(["open", "assigned", "done"]);
11833
+ var NoteStatusSchema = external_exports.enum(["open", "assigned", "in_progress", "done"]);
11834
+ var NoteRepeatSchema = external_exports.enum(["once", "until_done"]);
11725
11835
  var DecisionSchema = external_exports.object({
11726
11836
  id: external_exports.string(),
11727
11837
  /** The note this decision refines; null = recorded on a bare thread (the
@@ -11746,11 +11856,29 @@ var NoteSchema = external_exports.object({
11746
11856
  assignee: external_exports.string().nullable(),
11747
11857
  /** The request thread minted at assignment; null until assigned. */
11748
11858
  parentId: external_exports.string().nullable(),
11859
+ /** REMINDERS (reminders-design.md): the NOT-BEFORE this becomes eligible to ride a
11860
+ * call — never a deadline, and nothing rings when it passes. Null = "the very next
11861
+ * call", the right reading of "remind me to…" with no time attached. */
11862
+ // Defaulted, not required: a Note from an API deploy older than the reminders
11863
+ // migration has none of these, and the defaults ARE what it means — no not-before,
11864
+ // one ride, never ridden. Parsing must not fail across a rolling deploy.
11865
+ dueAt: external_exports.string().nullable().default(null),
11866
+ repeat: NoteRepeatSchema.default("once"),
11867
+ /** How many calls have already carried it — the fatigue cap counts rides, not days. */
11868
+ rides: external_exports.number().int().default(0),
11869
+ lastRideAt: external_exports.string().nullable().default(null),
11749
11870
  createdAt: external_exports.string()
11750
11871
  });
11751
11872
  var CreateNoteSchema = external_exports.object({
11752
11873
  /** The intent, in the user's own words. Stored verbatim; the broker only titles it. */
11753
- text: external_exports.string().min(1).max(4e3)
11874
+ text: external_exports.string().min(1).max(4e3),
11875
+ /** Capture it as a REMINDER — a note assigned to the user themselves, which rides
11876
+ * their next call instead of being handed to an agent. Everything else about the
11877
+ * note is identical; this is the one parameter that separates the two. */
11878
+ forMe: external_exports.boolean().optional(),
11879
+ /** The not-before, when the user already said one. Absent = the very next call. */
11880
+ dueAt: external_exports.string().datetime().optional(),
11881
+ repeat: NoteRepeatSchema.optional()
11754
11882
  });
11755
11883
  var RecordDecisionSchema = external_exports.object({
11756
11884
  /** An open decision (from /clarify) to answer. */
@@ -12475,10 +12603,14 @@ async function claimSessions(opts = {}) {
12475
12603
  return (await res.json()).sessions;
12476
12604
  }
12477
12605
  async function heartbeat(runtime, opts = {}) {
12606
+ const body = {
12607
+ ...runtime !== void 0 ? { runtime } : {},
12608
+ ...opts.activity !== void 0 ? { activity: opts.activity } : {}
12609
+ };
12478
12610
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/presence`, {
12479
12611
  method: "POST",
12480
12612
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
12481
- ...runtime !== void 0 ? { body: JSON.stringify({ runtime }) } : {}
12613
+ ...Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}
12482
12614
  }));
12483
12615
  if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
12484
12616
  }
@@ -12632,497 +12764,212 @@ function resolveWakeDir(pinned, deps) {
12632
12764
  return listWorkspaces(deps)[0];
12633
12765
  }
12634
12766
 
12635
- // src/harness/session.ts
12636
- import { spawn } from "child_process";
12637
- import { existsSync as existsSync4 } from "fs";
12638
- import { homedir as homedir4 } from "os";
12639
- import { resolve as resolve2, delimiter as delimiter2 } from "path";
12640
-
12641
- // src/harness/acp.ts
12642
- var none = { events: [], writes: [] };
12643
- function optionFor(options, decision) {
12644
- const want = decision.allow ? "allow_once" : "reject_once";
12645
- return options.find((o) => o.kind === want)?.optionId ?? null;
12646
- }
12647
- function createAcpDriver(opts) {
12648
- return new AcpDriver(opts.cwd, opts.mode, opts.mcp ?? []);
12649
- }
12650
- var AcpDriver = class {
12651
- constructor(cwd, mode, mcp = []) {
12652
- this.cwd = cwd;
12653
- this.mode = mode;
12654
- this.mcp = mcp;
12655
- }
12656
- cwd;
12657
- mode;
12658
- mcp;
12659
- nextId = 1;
12660
- initId;
12661
- sessionNewId;
12662
- promptId;
12663
- sessionId;
12664
- queued = [];
12665
- /** Options of each unanswered permission request, keyed by its JSON-RPC id. */
12666
- pending = /* @__PURE__ */ new Map();
12667
- /** Where the REPORT starts in `text` — everything before the LAST tool call is working
12668
- * narration ("Now the API endpoints." runs a tool), and it used to ship: the chunks
12669
- * concatenate with no separator, so the owner's phone got "…find the repo.Now I have
12670
- * the full picture. Writing the migration.Now…" as the opening paragraph of a finished
12671
- * task (live, 2026-08-11 "looks like a working log"). The narration's audience is the
12672
- * terminal and host.log; what the agent composed AFTER its last tool call is the part
12673
- * addressed to a human, and that is what leaves the machine. */
12674
- reportFrom = 0;
12675
- /** The turn being streamed: text accumulates, tools append, both flush on stopReason. */
12676
- text = "";
12677
- tools = [];
12678
- /** The opening frame. Everything after is driven by responses in `handleLine`. */
12679
- open() {
12680
- this.initId = this.nextId++;
12681
- return [
12682
- frame({
12683
- id: this.initId,
12684
- method: "initialize",
12685
- params: {
12686
- protocolVersion: 2,
12687
- // We are not an editor: no file services offered, the agent uses its own.
12688
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
12689
- clientInfo: { name: "paigy-desktop", version: "0.0.0" }
12690
- }
12691
- })
12692
- ];
12693
- }
12694
- /** Queue a prompt; it goes out when the session exists and no turn is running. */
12695
- send(text) {
12696
- this.queued.push(text);
12697
- return this.flush();
12698
- }
12699
- /** Answer a PermissionEvent (ask mode). The id is the request's JSON-RPC id as a string. */
12700
- respond(id, decision) {
12701
- const request = this.pending.get(id);
12702
- if (!request) return [];
12703
- this.pending.delete(id);
12704
- const optionId = optionFor(request.options, decision);
12705
- return [
12706
- optionId ? frame({ id: request.id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id: request.id, result: { outcome: { outcome: "cancelled" } } })
12707
- ];
12708
- }
12709
- /** Cancel anything still blocked — called on stop so the process can exit cleanly. */
12710
- close() {
12711
- const writes = [...this.pending.values()].map(
12712
- (r) => frame({ id: r.id, result: { outcome: { outcome: "cancelled" } } })
12713
- );
12714
- this.pending.clear();
12715
- return writes;
12716
- }
12717
- handleLine(line) {
12718
- const trimmed = line.trim();
12719
- if (!trimmed) return none;
12720
- let msg;
12721
- try {
12722
- msg = JSON.parse(trimmed);
12723
- } catch {
12724
- return none;
12725
- }
12726
- if (msg.method !== void 0) {
12727
- return msg.id !== void 0 ? this.handleRequest(msg) : this.handleNotification(msg);
12728
- }
12729
- if (msg.id !== void 0) return this.handleResponse(msg);
12730
- return none;
12731
- }
12732
- // ── responses to our requests ──
12733
- handleResponse(msg) {
12734
- if (msg.id === this.initId) {
12735
- this.initId = void 0;
12736
- if (msg.error) {
12737
- return { events: [{ kind: "error", message: `initialize failed: ${msg.error.message}` }], writes: [] };
12738
- }
12739
- this.sessionNewId = this.nextId++;
12740
- return {
12741
- events: [],
12742
- writes: [frame({ id: this.sessionNewId, method: "session/new", params: { cwd: this.cwd, mcpServers: this.mcp } })]
12743
- };
12744
- }
12745
- if (msg.id === this.sessionNewId) {
12746
- this.sessionNewId = void 0;
12747
- const sessionId = msg.result?.sessionId;
12748
- if (!sessionId) {
12749
- return {
12750
- events: [{ kind: "error", message: `session/new failed: ${msg.error?.message ?? "no sessionId"}` }],
12751
- writes: []
12752
- };
12753
- }
12754
- this.sessionId = sessionId;
12755
- const value = this.mode === "bypass" ? "bypassPermissions" : "default";
12756
- const writes = [
12757
- frame({
12758
- id: this.nextId++,
12759
- method: "session/set_config_option",
12760
- params: { sessionId, configId: "mode", value }
12761
- }),
12762
- frame({ id: this.nextId++, method: "session/set_mode", params: { sessionId, modeId: value } })
12763
- ];
12764
- writes.push(...this.flush());
12765
- return { events: [], writes };
12766
- }
12767
- if (msg.id === this.promptId) {
12768
- this.promptId = void 0;
12769
- const events = [];
12770
- const report = this.text.slice(this.reportFrom).trim() || this.text.trim();
12771
- if (report || this.tools.length) {
12772
- events.push({
12773
- kind: "turn",
12774
- role: "agent",
12775
- text: report,
12776
- ...this.tools.length ? { tools: [...this.tools] } : {}
12777
- });
12778
- }
12779
- const result = report;
12780
- const failed = msg.error !== void 0 || msg.result?.stopReason === "refusal";
12781
- this.text = "";
12782
- this.tools = [];
12783
- this.reportFrom = 0;
12784
- const writes = this.flush();
12785
- if (!writes.length) {
12786
- events.push({
12787
- kind: "idle",
12788
- ...result ? { result } : {},
12789
- ...failed ? { failed: true } : {}
12790
- });
12791
- }
12792
- return { events, writes };
12793
- }
12794
- return none;
12795
- }
12796
- // ── the agent talking to us ──
12797
- handleNotification(msg) {
12798
- if (msg.method !== "session/update") return none;
12799
- const update = msg.params?.update;
12800
- switch (update?.sessionUpdate) {
12801
- case "agent_message_chunk":
12802
- this.text += update.content?.text ?? "";
12803
- return none;
12804
- case "tool_call": {
12805
- const title = update.title?.trim() || update.kind || "tool";
12806
- this.tools.push(title);
12807
- const note = this.text.slice(this.reportFrom).trim();
12808
- this.reportFrom = this.text.length;
12809
- return { events: [{ kind: "work", tool: title, ...note ? { note } : {} }], writes: [] };
12810
- }
12811
- default:
12812
- return none;
12813
- }
12814
- }
12815
- handleRequest(msg) {
12816
- if (msg.method !== "session/request_permission") {
12817
- return {
12818
- events: [],
12819
- writes: [frame({ id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } })]
12820
- };
12821
- }
12822
- const id = msg.id;
12823
- const options = msg.params?.options ?? [];
12824
- const title = msg.params?.toolCall?.title?.trim() || "a tool call";
12825
- const tool = msg.params?.toolCall?.kind ?? "tool";
12826
- if (this.mode === "bypass") {
12827
- const optionId = optionFor(options, { allow: true }) ?? optionFor(options, { allow: false });
12828
- return {
12829
- // The decision still goes through Paigy — as history, not a question. Bypass
12830
- // means "don't stall the agent", never "don't tell the user".
12831
- events: [{ kind: "turn", role: "agent", text: `auto-approved: ${title}`, tools: [tool] }],
12832
- writes: [
12833
- optionId ? frame({ id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id, result: { outcome: { outcome: "cancelled" } } })
12834
- ]
12835
- };
12836
- }
12837
- this.pending.set(String(id), { id, options });
12838
- return {
12839
- events: [{ kind: "permission", id: String(id), tool, summary: title }],
12840
- writes: []
12841
- };
12842
- }
12843
- /** Send the queued prompts as one turn, if the agent can take one right now. */
12844
- flush() {
12845
- if (!this.sessionId || this.promptId !== void 0 || !this.queued.length) return [];
12846
- const blocks = this.queued.map((text) => ({ type: "text", text }));
12847
- this.queued = [];
12848
- this.promptId = this.nextId++;
12849
- return [
12850
- frame({
12851
- id: this.promptId,
12852
- method: "session/prompt",
12853
- params: { sessionId: this.sessionId, prompt: blocks }
12854
- })
12855
- ];
12856
- }
12857
- };
12858
- var frame = (body) => JSON.stringify({ jsonrpc: "2.0", ...body });
12859
-
12860
- // src/harness/session.ts
12861
- var ADAPTER_BIN = {
12862
- claude: "claude-agent-acp",
12863
- codex: "codex-acp",
12864
- agy: "agy"
12865
- };
12866
- function splitLines(buffer, chunk) {
12867
- const combined = buffer + chunk;
12868
- const parts = combined.split("\n");
12869
- const rest = parts.pop() ?? "";
12870
- return { lines: parts.filter((l) => l.trim()), rest };
12871
- }
12872
- function startSession(opts) {
12873
- const cwd = resolve2(opts.cwd.replace(/^~(?=$|\/)/, homedir4()));
12874
- if (!existsSync4(cwd)) {
12875
- queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
12876
- }
12877
- const child = (opts.spawnFn ?? spawn)(opts.bin ?? ADAPTER_BIN[opts.harness], [], {
12878
- cwd,
12879
- // The adapter shells out to its vendor CLI (`claude`, `codex`), and a GUI- or
12880
- // launchd-launched process's PATH won't have it — probe dirs plus the RUNNING
12881
- // node's own bin dir (nvm installs the adapters next to node; launchd's bare
12882
- // PATH knows neither — live catch 2026-08-04: the first slot-wake ENOENT'd).
12883
- env: {
12884
- ...process.env,
12885
- PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
12886
- ...opts.token ? { PAIGY_TOKEN: opts.token } : {}
12887
- },
12888
- stdio: ["pipe", "pipe", "pipe"]
12889
- });
12890
- const write = (frames) => {
12891
- for (const f of frames) child.stdin?.write(`${f}
12892
- `);
12893
- };
12894
- child.stderr?.on("data", (chunk) => {
12895
- const text = String(chunk).trim();
12896
- if (text) opts.onEvent({ kind: "error", message: text });
12897
- });
12898
- child.on("exit", (code) => {
12899
- opts.onEvent({ kind: "idle", failed: code !== 0, ...code ? { result: `exited ${code}` } : {} });
12900
- opts.onExit?.();
12901
- });
12902
- child.on("error", (e) => {
12903
- opts.onEvent({ kind: "error", message: e.message });
12904
- opts.onExit?.();
12905
- });
12906
- const driver = createAcpDriver({ cwd, mode: opts.mode, ...opts.mcp ? { mcp: opts.mcp } : {} });
12907
- let buffer = "";
12908
- child.stdout?.on("data", (chunk) => {
12909
- const { lines, rest } = splitLines(buffer, String(chunk));
12910
- buffer = rest;
12911
- for (const line of lines) {
12912
- const { events, writes } = driver.handleLine(line);
12913
- write(writes);
12914
- for (const event of events) opts.onEvent(event);
12915
- }
12916
- });
12917
- write(driver.open());
12918
- return {
12919
- send(text) {
12920
- write(driver.send(text));
12921
- },
12922
- respond(id, decision) {
12923
- write(driver.respond(id, decision));
12924
- },
12925
- stop() {
12926
- write(driver.close());
12927
- child.kill();
12928
- }
12929
- };
12930
- }
12931
-
12932
- // ../../packages/schema/dist/index.js
12933
- var ContextSchema2 = external_exports.object({
12934
- title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
12935
- description: external_exports.array(external_exports.string().min(1)).describe(
12936
- "Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand. MAY BE EMPTY: a claim whose whole content is its heading \u2014 a single sentence \u2014 has no body, and saying so beats repeating the heading underneath itself. That repeat is what `min(1)` used to force, at 2x the storage, with every reader subtracting it back out at render time."
12937
- )
12938
- });
12939
- var ParticipantSchema2 = external_exports.object({
12940
- kind: external_exports.enum(["human", "agent"]),
12941
- id: external_exports.string()
12942
- });
12943
- var TransformSchema2 = external_exports.enum([
12944
- "structure",
12945
- // shape intent into an answer contract; pick channel/urgency — broker `ask`, `select` shapes, `points`
12946
- "request_more",
12947
- // clarify / follow-ups / uncovered points; escalate inbox→call — {kind:'clarify'}, escalate, blocking
12948
- "redirect",
12949
- // seed / hand off a thread to a new recipient — handoff, "new session from this"
12950
- "break_down",
12951
- // one bundle → many sub-asks — checklist fan-out, `points`
12952
- "coalesce",
12953
- // many bundles → one — morning triage (#347), threading-supersede, digest
12954
- "organize",
12955
- // group related bundles onto one thread — threading (`parentId`), parent/clarify links
12956
- "summarize"
12957
- // reduce volume, keep decision value — 30-turn cap, spoken briefing
12958
- ]);
12959
- var OptionSchema2 = external_exports.object({
12960
- id: external_exports.string(),
12961
- label: external_exports.string(),
12962
- // .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
12963
- // the constraints below are what an agent reads when deciding to use these.
12964
- html: external_exports.string().max(16384).describe(
12965
- "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
12966
- ).optional(),
12967
- image: external_exports.string().url().describe(
12968
- "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
12969
- ).optional()
12970
- });
12971
- var VisualSchema2 = external_exports.object({
12972
- url: external_exports.string().url(),
12973
- label: external_exports.string().optional()
12974
- });
12975
- var NotifyLevelSchema2 = external_exports.enum(["inbox", "push", "banner", "call"]);
12976
- var SelectShapeSchema2 = external_exports.enum(["one", "many", "rank", "confirm", "text"]);
12977
- var ReceiptEventSchema2 = external_exports.enum([
12978
- "delivered",
12979
- // the bundle reached the recipient at some level
12980
- "seen",
12981
- // the recipient opened it
12982
- "answered",
12983
- // the recipient replied
12984
- "escalated",
12985
- // re-reached at a higher level (re-ring / promote)
12986
- "coalesced",
12987
- // merged into another live claim
12988
- "expired",
12989
- // deadline passed unanswered
12990
- "woke",
12991
- // the agent was woken for an owed obligation (callback)
12992
- "gave_up"
12993
- // the budget was spent — stopped re-engaging
12994
- ]);
12995
- var AttentionSchema2 = external_exports.object({
12996
- urgency: NotifyLevelSchema2,
12997
- /** The required answer shape, or null for a plain notify that asks nothing back. */
12998
- select: SelectShapeSchema2.nullable(),
12999
- /** Coverage contract (#396) — points the answer must address; null = none declared. */
13000
- points: external_exports.array(external_exports.string()).nullable(),
13001
- /** Whether the ask blocks the sender — what lets arbitration escalate it on silence. */
13002
- blocking: external_exports.boolean(),
13003
- /** Reserved (MODEL.md lists it): a response deadline. No row column yet — a later Phase 2
13004
- * slice wires it; optional so today's rows/callers project cleanly. */
13005
- deadline: external_exports.string().datetime().nullable().optional()
13006
- });
13007
- var NotifyRequestSchema2 = external_exports.object({
13008
- /** Plaintext message content. Present on the plaintext path (today's shape);
13009
- * ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
13010
- * superRefine at the bottom enforces exactly one of the two. */
13011
- context: ContextSchema2.optional(),
13012
- options: external_exports.array(OptionSchema2.omit({ id: true })).min(1).optional().describe(
13013
- "The choices, in order \u2014 required when select is 'one'/'many'/'rank', omitted otherwise. Ids are assigned automatically by position ('1', '2', \u2026); the user's answer references them as optionId(s)."
13014
- ),
13015
- points: external_exports.array(external_exports.string().min(1)).optional().describe(
13016
- "The distinct things you need answered, each a short phrase \u2014 on a call the broker keeps the conversation going until each is addressed, and the reply reports which were covered, so a half-answer is never silently returned as final. Omit for single-part asks."
13017
- ),
13018
- visuals: external_exports.array(VisualSchema2).optional().describe(
13019
- "Images attached to the message itself \u2014 context for the whole question (a screenshot, a chart). For a preview on one selectable choice, use that option's `html`/`image` instead."
13020
- ),
13021
- /** Git repo the agent is working in ("owner/name"). Local MCP fills this from the checkout — omit unless overriding. */
13022
- repo: external_exports.string().optional(),
13023
- /** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
13024
- branch: external_exports.string().optional(),
13025
- /** Continue an existing conversation — the id of any notification in it (its root
13026
- * is the conversation's identity). Omitted = start a new conversation. Renamed
13027
- * from `parentId` (2026-08-03): one linkage system, the parent; the API edge
13028
- * still accepts the old name from older clients. */
13029
- parentId: external_exports.string().uuid().optional(),
13030
- urgency: NotifyLevelSchema2.default("inbox").describe(
13031
- "The level you're requesting \u2014 the user's account permissions + session mode can lower it. 'inbox' (default) = sits silently in the inbox for the user to get to. 'push' = a quiet passive push (lands in Notification Center, no sound) \u2014 a gentle heads-up. 'banner' = a time-sensitive banner/lock-screen push with sound (a 'paige') they tap to open \u2014 use when you need them soon-ish but it's not worth ringing them. 'call' = rings the user's phone now (a CallKit voice call) \u2014 use only when you genuinely need them in the moment (blocked and waiting, time-sensitive). context.title is what they see on the banner/ring, so make it specific."
13032
- ),
13033
- /** The request this one CLARIFIES — spawning a clarification keeps that parent
13034
- * visible and marks it needs_input. Renamed from the old `parentId` (2026-08-03)
13035
- * when `parentId` became the conversation handle: `parentId` says WHERE, this
13036
- * says HOW. */
13037
- clarifies: external_exports.string().optional(),
13038
- /** E2EE (text lane): when the pairing is E2EE, the sealed replacements for the
13039
- * plaintext content fields, keyed by field name. FINALIZED wire shape (was
13040
- * provisional in the storage PR): a per-field map `{ context?, options?,
13041
- * visuals? }` of opaque Envelopes — one seal per present content field, so a
13042
- * message with no options/visuals seals only `context`. It COEXISTS with the
13043
- * plaintext fields by mutual exclusion: the superRefine below requires that
13044
- * when `envelope` is present the plaintext `context`/`options`/`visuals` are
13045
- * ABSENT (and vice-versa), so a row is either fully plaintext or fully sealed —
13046
- * never a readable half. The server persists this OPAQUELY into
13047
- * notifications.envelope and relays it blindly; it never decrypts. Absent =
13048
- * today's plaintext path (context/options/visuals carry the cleartext).
13049
- * z.lazy because EnvelopeSchema is declared further down (E2EE section). */
13050
- envelope: external_exports.object({
13051
- context: external_exports.lazy(() => EnvelopeSchema2).optional(),
13052
- options: external_exports.lazy(() => EnvelopeSchema2).optional(),
13053
- visuals: external_exports.lazy(() => EnvelopeSchema2).optional()
13054
- }).optional(),
13055
- select: SelectShapeSchema2.optional().describe(
13056
- "How the user answers \u2014 required on the fully-shaped form, pick the shape that fits the question: 'one' = pick one option, 'many' = pick several, 'rank' = pick & order (each needs `options`); 'confirm' = yes/no or approve/deny; 'text' = free-form reply only (status updates, open questions). 'confirm' and 'text' take no options. Omit only when sending the simplified `ask` form \u2014 the broker picks the shape."
13057
- ),
13058
- /** The simplified form (#395): instead of shaping the notification yourself
13059
- * (context/select/options/urgency), state what you need to learn and why it
13060
- * matters now — the broker derives the optimal shape and channel. Mutually
13061
- * exclusive with `context` (and never sent alongside `envelope`: E2EE pairings
13062
- * derive agent-side before sealing, so the server only ever shapes plaintext). */
13063
- // 10k, not a sentence budget. What the human hears is bounded by the BROKER — it splits
13064
- // the ask into topics and gives each one at most three sentences and one question
13065
- // (broker/agenda-design.md) — not by a wire cap the agent has to pre-summarize under.
13066
- // Owner, 2026-07-28: "our actual limitation on how long something is to the user should
13067
- // come from the broker splitting and summarizing." The cap that remains is a size guard.
13068
- ask: external_exports.string().min(1).max(1e4).optional().describe(
13069
- 'SIMPLIFIED FORM \u2014 state in plain prose what you need to learn from the user and why it matters now (e.g. "I need to know whether to deploy the auth fix \u2014 tests are green, staging verified"). Write as much as the situation needs (up to 10k characters) \u2014 Paigy breaks it into topics and reads it back a few sentences at a time; do NOT pre-summarize it into one line. Paigy derives the title, answer shape, options, and delivery channel for you. Mutually exclusive with context/select/options \u2014 send one form or the other.'
13070
- ),
13071
- needs: external_exports.array(external_exports.string().min(1)).optional().describe(
13072
- "With `ask` only: the distinct things you need answered when the ask is multi-part \u2014 becomes the coverage contract (`points`), so a half-answer is never silently final."
13073
- ),
13074
- urgencyHint: external_exports.enum(["whenever", "soon", "now"]).optional().describe(
13075
- "With `ask` only: how urgently you need the answer \u2014 'whenever' (inbox), 'soon' (worth a heads-up), 'now' (you're blocked this minute). A hint, not a command: the user's settings still have the final word."
13076
- ),
13077
- /** #575: the ONE self-report that replaces urgencyHint + blocking — what happens
13078
- * to the agent's work while it waits. Normalized server-side into those two
13079
- * fields (normalizeWaiting) so everything downstream is untouched; explicit
13080
- * urgencyHint/blocking win when both are sent. */
13081
- waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
13082
- "With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
13083
- ),
13084
- /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
13085
- * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
13086
- * holding by default would charge every quiet claim that minute before any agent could
13087
- * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
13088
- * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
13089
- confirm: external_exports.boolean().optional().describe(
13090
- "Hold this one so you can correct the plan before the user is interrupted. The response comes back with `held: true` and the plan; POST the confirm route to release it (with options/visuals/urgency corrections, or nothing at all). If you never do, it is announced anyway a couple of minutes later. Ignored when waiting is 'hard'."
13091
- ),
13092
- /** #575: a RELAY of the user's explicitly stated preference, never the agent's
13093
- * choice. Outranks waiting in both directions: 'call' rings even for a
13094
- * waiting:'none' "call me when it's done"; 'message' never rings even for
13095
- * waiting:'hard'. */
13096
- channel: external_exports.enum(["call", "message"]).optional().describe(
13097
- "Only if the user explicitly said how to reach them \u2014 'call me' \u2192 'call', 'just message/text me' \u2192 'message'. Omit otherwise; Paigy picks."
13098
- ),
13099
- confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
13100
- "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
13101
- ),
13102
- blocking: external_exports.boolean().default(false).describe(
13103
- "Set true when real downstream work is stuck behind this specific decision \u2014 you can't make meaningful progress until it's answered. This is the real signal for how urgently the user should be reached; it's what the premier use case (an agent that stays unblocked instead of going idle) depends on. Independent of `urgency`: a `banner`-level question can still be `blocking` (something IS stuck, just not time-critical enough to ring for immediately) \u2014 if it goes unanswered a while, Paigy escalates it to a real call using this flag rather than guessing from how many other things happen to be pending. Leave false for anything you could work around, defer, or where other useful work exists meanwhile."
13104
- )
13105
- }).superRefine((r, ctx) => {
13106
- const sealed = !!r.envelope;
13107
- if (sealed) {
13108
- if (!r.envelope?.context)
13109
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["envelope", "context"], message: "sealed request must include envelope.context" });
13110
- for (const f of ["context", "options", "visuals"]) {
13111
- if (r[f] !== void 0)
13112
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: [f], message: `E2EE request must not carry plaintext ${f} \u2014 it's sealed in envelope.${f}` });
13113
- }
13114
- if (r.points !== void 0)
13115
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["points"], message: "E2EE request must not carry plaintext points" });
13116
- if (r.ask !== void 0)
13117
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["ask"], message: "E2EE request must not carry a plaintext ask \u2014 derive the shape agent-side and seal it" });
13118
- if (r.needs !== void 0)
13119
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["needs"], message: "E2EE request must not carry plaintext needs" });
13120
- return;
12767
+ // ../../packages/schema/dist/index.js
12768
+ var ContextSchema2 = external_exports.object({
12769
+ title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
12770
+ description: external_exports.array(external_exports.string().min(1)).describe(
12771
+ "Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand. MAY BE EMPTY: a claim whose whole content is its heading \u2014 a single sentence \u2014 has no body, and saying so beats repeating the heading underneath itself. That repeat is what `min(1)` used to force, at 2x the storage, with every reader subtracting it back out at render time."
12772
+ )
12773
+ });
12774
+ var ParticipantSchema2 = external_exports.object({
12775
+ kind: external_exports.enum(["human", "agent"]),
12776
+ id: external_exports.string()
12777
+ });
12778
+ var TransformSchema2 = external_exports.enum([
12779
+ "structure",
12780
+ // shape intent into an answer contract; pick channel/urgency — broker `ask`, `select` shapes, `points`
12781
+ "request_more",
12782
+ // clarify / follow-ups / uncovered points; escalate inbox→call — {kind:'clarify'}, escalate, blocking
12783
+ "redirect",
12784
+ // seed / hand off a thread to a new recipient — handoff, "new session from this"
12785
+ "break_down",
12786
+ // one bundle → many sub-asks — checklist fan-out, `points`
12787
+ "coalesce",
12788
+ // many bundles → one — morning triage (#347), threading-supersede, digest
12789
+ "organize",
12790
+ // group related bundles onto one thread — threading (`parentId`), parent/clarify links
12791
+ "summarize"
12792
+ // reduce volume, keep decision value — 30-turn cap, spoken briefing
12793
+ ]);
12794
+ var OptionSchema2 = external_exports.object({
12795
+ id: external_exports.string(),
12796
+ label: external_exports.string(),
12797
+ // .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
12798
+ // the constraints below are what an agent reads when deciding to use these.
12799
+ html: external_exports.string().max(16384).describe(
12800
+ "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
12801
+ ).optional(),
12802
+ image: external_exports.string().url().describe(
12803
+ "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
12804
+ ).optional()
12805
+ });
12806
+ var VisualSchema2 = external_exports.object({
12807
+ url: external_exports.string().url(),
12808
+ label: external_exports.string().optional()
12809
+ });
12810
+ var NotifyLevelSchema2 = external_exports.enum(["inbox", "push", "banner", "call"]);
12811
+ var SelectShapeSchema2 = external_exports.enum(["one", "many", "rank", "confirm", "text"]);
12812
+ var ReceiptEventSchema2 = external_exports.enum([
12813
+ "delivered",
12814
+ // the bundle reached the recipient at some level
12815
+ "seen",
12816
+ // the recipient opened it
12817
+ "answered",
12818
+ // the recipient replied
12819
+ // The recipient TURNED THE RING DOWN CallKit ended it and no answer was ever tapped.
12820
+ // Written by the phone, on the same door that reports the ring itself, so it exists only
12821
+ // when a ring reached a running app and a person did not take it. That is what separates
12822
+ // it from "a ring with no answer", which our own crashes wrote just as readily and which
12823
+ // is why the responsiveness back-off had to be removed (#1144).
12824
+ "declined",
12825
+ "escalated",
12826
+ // re-reached at a higher level (re-ring / promote)
12827
+ "coalesced",
12828
+ // merged into another live claim
12829
+ "expired",
12830
+ // deadline passed unanswered
12831
+ "woke",
12832
+ // the agent was woken for an owed obligation (callback)
12833
+ "gave_up",
12834
+ // the budget was spent — stopped re-engaging
12835
+ // The ladder starts over — a silent pickup (the owner's fresh-miss rule, 2026-07-28), a
12836
+ // promote to call, a re-delivery. APPENDED, never a rewind: `ring_step` was a cache of
12837
+ // the escalated-count and a writer rewound it to 0 on every unanswered call (2026-08-24,
12838
+ // nine rings in an hour, `gaveUp` unreachable). A count since the last `restarted` cannot
12839
+ // be rewound by a writer that forgot to advance it.
12840
+ "restarted"
12841
+ ]);
12842
+ var AttentionSchema2 = external_exports.object({
12843
+ urgency: NotifyLevelSchema2,
12844
+ /** The required answer shape, or null for a plain notify that asks nothing back. */
12845
+ select: SelectShapeSchema2.nullable(),
12846
+ /** Coverage contract (#396) — points the answer must address; null = none declared. */
12847
+ points: external_exports.array(external_exports.string()).nullable(),
12848
+ /** Whether the ask blocks the sender — what lets arbitration escalate it on silence. */
12849
+ blocking: external_exports.boolean(),
12850
+ /** Reserved (MODEL.md lists it): a response deadline. No row column yet — a later Phase 2
12851
+ * slice wires it; optional so today's rows/callers project cleanly. */
12852
+ deadline: external_exports.string().datetime().nullable().optional()
12853
+ });
12854
+ var NotifyRequestSchema2 = external_exports.object({
12855
+ /** Plaintext message content. Present on the plaintext path (today's shape);
12856
+ * ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
12857
+ * superRefine at the bottom enforces exactly one of the two. */
12858
+ context: ContextSchema2.optional(),
12859
+ options: external_exports.array(OptionSchema2.omit({ id: true })).min(1).optional().describe(
12860
+ "The choices, in order \u2014 required when select is 'one'/'many'/'rank', omitted otherwise. Ids are assigned automatically by position ('1', '2', \u2026); the user's answer references them as optionId(s)."
12861
+ ),
12862
+ points: external_exports.array(external_exports.string().min(1)).optional().describe(
12863
+ "The distinct things you need answered, each a short phrase \u2014 on a call the broker keeps the conversation going until each is addressed, and the reply reports which were covered, so a half-answer is never silently returned as final. Omit for single-part asks."
12864
+ ),
12865
+ visuals: external_exports.array(VisualSchema2).optional().describe(
12866
+ "Images attached to the message itself \u2014 context for the whole question (a screenshot, a chart). For a preview on one selectable choice, use that option's `html`/`image` instead."
12867
+ ),
12868
+ /** Git repo the agent is working in ("owner/name"). Local MCP fills this from the checkout — omit unless overriding. */
12869
+ repo: external_exports.string().optional(),
12870
+ /** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
12871
+ branch: external_exports.string().optional(),
12872
+ /** Continue an existing conversation — the id of any notification in it (its root
12873
+ * is the conversation's identity). Omitted = start a new conversation. Renamed
12874
+ * from `parentId` (2026-08-03): one linkage system, the parent; the API edge
12875
+ * still accepts the old name from older clients. */
12876
+ parentId: external_exports.string().uuid().optional(),
12877
+ urgency: NotifyLevelSchema2.default("inbox").describe(
12878
+ "The level you're requesting \u2014 the user's account permissions + session mode can lower it. 'inbox' (default) = sits silently in the inbox for the user to get to. 'push' = a quiet passive push (lands in Notification Center, no sound) \u2014 a gentle heads-up. 'banner' = a time-sensitive banner/lock-screen push with sound (a 'paige') they tap to open \u2014 use when you need them soon-ish but it's not worth ringing them. 'call' = rings the user's phone now (a CallKit voice call) \u2014 use only when you genuinely need them in the moment (blocked and waiting, time-sensitive). context.title is what they see on the banner/ring, so make it specific."
12879
+ ),
12880
+ /** The request this one CLARIFIES — spawning a clarification keeps that parent
12881
+ * visible and marks it needs_input. Renamed from the old `parentId` (2026-08-03)
12882
+ * when `parentId` became the conversation handle: `parentId` says WHERE, this
12883
+ * says HOW. */
12884
+ clarifies: external_exports.string().optional(),
12885
+ /** E2EE (text lane): when the pairing is E2EE, the sealed replacements for the
12886
+ * plaintext content fields, keyed by field name. FINALIZED wire shape (was
12887
+ * provisional in the storage PR): a per-field map `{ context?, options?,
12888
+ * visuals? }` of opaque Envelopes — one seal per present content field, so a
12889
+ * message with no options/visuals seals only `context`. It COEXISTS with the
12890
+ * plaintext fields by mutual exclusion: the superRefine below requires that
12891
+ * when `envelope` is present the plaintext `context`/`options`/`visuals` are
12892
+ * ABSENT (and vice-versa), so a row is either fully plaintext or fully sealed —
12893
+ * never a readable half. The server persists this OPAQUELY into
12894
+ * notifications.envelope and relays it blindly; it never decrypts. Absent =
12895
+ * today's plaintext path (context/options/visuals carry the cleartext).
12896
+ * z.lazy because EnvelopeSchema is declared further down (E2EE section). */
12897
+ envelope: external_exports.object({
12898
+ context: external_exports.lazy(() => EnvelopeSchema2).optional(),
12899
+ options: external_exports.lazy(() => EnvelopeSchema2).optional(),
12900
+ visuals: external_exports.lazy(() => EnvelopeSchema2).optional()
12901
+ }).optional(),
12902
+ select: SelectShapeSchema2.optional().describe(
12903
+ "How the user answers \u2014 required on the fully-shaped form, pick the shape that fits the question: 'one' = pick one option, 'many' = pick several, 'rank' = pick & order (each needs `options`); 'confirm' = yes/no or approve/deny; 'text' = free-form reply only (status updates, open questions). 'confirm' and 'text' take no options. Omit only when sending the simplified `ask` form \u2014 the broker picks the shape."
12904
+ ),
12905
+ /** The simplified form (#395): instead of shaping the notification yourself
12906
+ * (context/select/options/urgency), state what you need to learn and why it
12907
+ * matters now — the broker derives the optimal shape and channel. Mutually
12908
+ * exclusive with `context` (and never sent alongside `envelope`: E2EE pairings
12909
+ * derive agent-side before sealing, so the server only ever shapes plaintext). */
12910
+ // 10k, not a sentence budget. What the human hears is bounded by the BROKER — it splits
12911
+ // the ask into topics and gives each one at most three sentences and one question
12912
+ // (broker/agenda-design.md) not by a wire cap the agent has to pre-summarize under.
12913
+ // Owner, 2026-07-28: "our actual limitation on how long something is to the user should
12914
+ // come from the broker splitting and summarizing." The cap that remains is a size guard.
12915
+ ask: external_exports.string().min(1).max(1e4).optional().describe(
12916
+ 'SIMPLIFIED FORM \u2014 state in plain prose what you need to learn from the user and why it matters now (e.g. "I need to know whether to deploy the auth fix \u2014 tests are green, staging verified"). Write as much as the situation needs (up to 10k characters) \u2014 Paigy breaks it into topics and reads it back a few sentences at a time; do NOT pre-summarize it into one line. Paigy derives the title, answer shape, options, and delivery channel for you. Mutually exclusive with context/select/options \u2014 send one form or the other.'
12917
+ ),
12918
+ needs: external_exports.array(external_exports.string().min(1)).optional().describe(
12919
+ "With `ask` only: the distinct things you need answered when the ask is multi-part \u2014 becomes the coverage contract (`points`), so a half-answer is never silently final."
12920
+ ),
12921
+ urgencyHint: external_exports.enum(["whenever", "soon", "now"]).optional().describe(
12922
+ "With `ask` only: how urgently you need the answer \u2014 'whenever' (inbox), 'soon' (worth a heads-up), 'now' (you're blocked this minute). A hint, not a command: the user's settings still have the final word."
12923
+ ),
12924
+ /** #575: the ONE self-report that replaces urgencyHint + blocking — what happens
12925
+ * to the agent's work while it waits. Normalized server-side into those two
12926
+ * fields (normalizeWaiting) so everything downstream is untouched; explicit
12927
+ * urgencyHint/blocking win when both are sent. */
12928
+ waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
12929
+ "With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
12930
+ ),
12931
+ /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
12932
+ * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
12933
+ * holding by default would charge every quiet claim that minute before any agent could
12934
+ * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
12935
+ * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
12936
+ confirm: external_exports.boolean().optional().describe(
12937
+ "Hold this one so you can correct the plan before the user is interrupted. The response comes back with `held: true` and the plan; POST the confirm route to release it (with options/visuals/urgency corrections, or nothing at all). If you never do, it is announced anyway a couple of minutes later. Ignored when waiting is 'hard'."
12938
+ ),
12939
+ /** #575: a RELAY of the user's explicitly stated preference, never the agent's
12940
+ * choice. Outranks waiting in both directions: 'call' rings even for a
12941
+ * waiting:'none' "call me when it's done"; 'message' never rings even for
12942
+ * waiting:'hard'. */
12943
+ channel: external_exports.enum(["call", "message"]).optional().describe(
12944
+ "Only if the user explicitly said how to reach them \u2014 'call me' \u2192 'call', 'just message/text me' \u2192 'message'. Omit otherwise; Paigy picks."
12945
+ ),
12946
+ confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
12947
+ "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
12948
+ ),
12949
+ blocking: external_exports.boolean().default(false).describe(
12950
+ "Set true when real downstream work is stuck behind this specific decision \u2014 you can't make meaningful progress until it's answered. This is the real signal for how urgently the user should be reached; it's what the premier use case (an agent that stays unblocked instead of going idle) depends on. Independent of `urgency`: a `banner`-level question can still be `blocking` (something IS stuck, just not time-critical enough to ring for immediately) \u2014 if it goes unanswered a while, Paigy escalates it to a real call using this flag rather than guessing from how many other things happen to be pending. Leave false for anything you could work around, defer, or where other useful work exists meanwhile."
12951
+ )
12952
+ }).superRefine((r, ctx) => {
12953
+ const sealed = !!r.envelope;
12954
+ if (sealed) {
12955
+ if (!r.envelope?.context)
12956
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["envelope", "context"], message: "sealed request must include envelope.context" });
12957
+ for (const f of ["context", "options", "visuals"]) {
12958
+ if (r[f] !== void 0)
12959
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: [f], message: `E2EE request must not carry plaintext ${f} \u2014 it's sealed in envelope.${f}` });
12960
+ }
12961
+ if (r.points !== void 0)
12962
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["points"], message: "E2EE request must not carry plaintext points" });
12963
+ if (r.ask !== void 0)
12964
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["ask"], message: "E2EE request must not carry a plaintext ask \u2014 derive the shape agent-side and seal it" });
12965
+ if (r.needs !== void 0)
12966
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["needs"], message: "E2EE request must not carry plaintext needs" });
12967
+ return;
13121
12968
  }
13122
12969
  if (r.ask !== void 0) {
13123
12970
  for (const f of ["context", "select", "points"]) {
13124
12971
  if (r[f] !== void 0)
13125
- ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it (use \`needs\` for multi-part asks)` });
12972
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives the answer shape from your prose. Drop ${f} and say it in \`ask\` instead ("should I\u2026" for approve/deny, "which of these\u2026" for a pick), passing \`options\` when you're offering concrete alternatives.` });
13126
12973
  }
13127
12974
  return;
13128
12975
  }
@@ -13197,6 +13044,14 @@ var IntentSchema2 = external_exports.object({
13197
13044
  * about what cannot answer "is the bot looping less this week?". */
13198
13045
  fault: external_exports.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
13199
13046
  });
13047
+ var RideAlongSchema2 = external_exports.object({
13048
+ /** The note this came from — assign/clarify/close it through /api/notes/:id. */
13049
+ noteId: external_exports.string(),
13050
+ /** What to do, in the owner's own words (the note's headline). Never model-rewritten. */
13051
+ text: external_exports.string(),
13052
+ /** The thread to report back on, when the note was dispatched over the request rail. */
13053
+ parentId: external_exports.string().nullable()
13054
+ });
13200
13055
  var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
13201
13056
  external_exports.object({
13202
13057
  type: external_exports.literal("reply"),
@@ -13217,7 +13072,13 @@ var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
13217
13072
  transcript: external_exports.string().optional(),
13218
13073
  /** Coverage report (#396), when the ask declared `points`: which of them this
13219
13074
  * answer addressed. Missing points = re-ask or proceed knowingly partial. */
13220
- covered: external_exports.array(external_exports.string()).optional()
13075
+ covered: external_exports.array(external_exports.string()).optional(),
13076
+ /** Ride-alongs (RideAlongSchema) — pending work for you, attached to the moment you
13077
+ * became free. Only `reply` and `idle` carry it: those are the two outcomes that
13078
+ * END a wait. `remind`, `superseded` and `turn` are mid-flight, and handing an
13079
+ * agent a side-quest while it is still holding the line is how the main thing gets
13080
+ * dropped. Absent/empty = nothing owed. */
13081
+ also: external_exports.array(RideAlongSchema2).optional()
13221
13082
  }),
13222
13083
  external_exports.object({
13223
13084
  type: external_exports.literal("remind"),
@@ -13252,7 +13113,7 @@ var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
13252
13113
  acts: external_exports.array(IntentSchema2).nullable().optional()
13253
13114
  })
13254
13115
  }),
13255
- external_exports.object({ type: external_exports.literal("idle") })
13116
+ external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema2).optional() })
13256
13117
  ]);
13257
13118
  var CallbackTriggerSchema2 = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
13258
13119
  var ScheduleCallbackSchema2 = external_exports.object({
@@ -13283,6 +13144,12 @@ var PendingRepliesSchema2 = external_exports.object({
13283
13144
  pending: external_exports.array(
13284
13145
  external_exports.object({ parentId: external_exports.string(), notificationId: external_exports.string(), createdAt: external_exports.string() })
13285
13146
  ),
13147
+ /** WHO YOU ARE on this account (field report 2026-08-28): the name and device the user
13148
+ * sees for this session's identity. From inside a session there was no way to find out —
13149
+ * `pair` with no arguments can HATCH a fresh identity, so it is not a safe probe — and an
13150
+ * agent that cannot tell which agent it is cannot tell whether work addressed to
13151
+ * "Reta" was addressed to it. Absent only for a token with no pairing behind it. */
13152
+ you: external_exports.object({ name: external_exports.string(), device: external_exports.string().nullable(), tokenId: external_exports.string() }).optional(),
13286
13153
  /** User-initiated requests addressed to this agent; act on them and reply via
13287
13154
  * contact on the same parentId. Keeps reappearing until you call
13288
13155
  * set_task_state on its notificationId. */
@@ -13294,12 +13161,21 @@ var PendingRepliesSchema2 = external_exports.object({
13294
13161
  createdAt: external_exports.string(),
13295
13162
  /** The user seeded this request with a past conversation — call get_thread on it
13296
13163
  * FIRST and treat the transcript as prior context (#57/#251). */
13297
- contextParentId: external_exports.string().optional()
13164
+ contextParentId: external_exports.string().optional(),
13165
+ /** STRANDED (field report 2026-08-28): this request was addressed to ANOTHER agent on
13166
+ * the account — the name here — which has not been seen since it landed, so nobody
13167
+ * came for it. Handed to you because you are the session that is here. Take it like
13168
+ * any request (set_task_state claims it, reply with contact on its parentId), and say
13169
+ * whose it was, because the user chose that agent on purpose. */
13170
+ stranded: external_exports.string().optional()
13298
13171
  })
13299
13172
  ),
13300
13173
  /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
13301
13174
  * if blocked, or at a time that has passed). Re-surfaced every sweep until you
13302
13175
  * fulfill one by calling contact on its parentId. */
13176
+ /** Ride-alongs (RideAlongSchema): notes assigned to this agent that no wake could
13177
+ * reach. Same array the contact/await replies carry — one queue, every carrier. */
13178
+ also: external_exports.array(RideAlongSchema2).optional(),
13303
13179
  owedCallbacks: external_exports.array(
13304
13180
  external_exports.object({ parentId: external_exports.string(), trigger: CallbackTriggerSchema2, note: external_exports.string() })
13305
13181
  ),
@@ -13335,7 +13211,11 @@ var NotifyResponseSchema2 = external_exports.object({
13335
13211
  status: NotifyStatusSchema2,
13336
13212
  createdAt: external_exports.string().datetime(),
13337
13213
  answer: UserAnswerSchema2.optional(),
13338
- answeredAt: external_exports.string().datetime().optional()
13214
+ answeredAt: external_exports.string().datetime().optional(),
13215
+ /** Ride-alongs for THIS agent — pending work it should pick up when it's done with
13216
+ * what it came for. Present on any reply, because an unwakeable agent's only
13217
+ * reliable moment is one it initiated. Absent/empty = nothing owed. */
13218
+ also: external_exports.array(RideAlongSchema2).optional()
13339
13219
  });
13340
13220
  var NotifyPlanUnitSchema2 = external_exports.object({
13341
13221
  notificationId: external_exports.string(),
@@ -13357,7 +13237,13 @@ var NotifyPlanUnitSchema2 = external_exports.object({
13357
13237
  proposal: external_exports.object({
13358
13238
  select: SelectShapeSchema2,
13359
13239
  options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
13360
- }).optional()
13240
+ }).optional(),
13241
+ /** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
13242
+ * between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
13243
+ * needs to know. Reported so the agent can correct a misread the same way it ratifies a
13244
+ * shape — the read RAISES (a decision always asks) and never silences a question the
13245
+ * agent declared (#731, #923). */
13246
+ wants: external_exports.enum(["decision", "approval", "knowledge"]).optional()
13361
13247
  });
13362
13248
  var NotifyPlanSchema2 = external_exports.object({
13363
13249
  units: external_exports.array(NotifyPlanUnitSchema2),
@@ -13381,6 +13267,9 @@ var UserResponseSchema2 = external_exports.object({
13381
13267
  });
13382
13268
  var VoiceKeySchema2 = external_exports.enum(["rachel", "george", "jessica", "brian", "lily"]);
13383
13269
  var AgendaTurnSchema2 = external_exports.object({
13270
+ /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
13271
+ * the planner declares duplicates instead of asking them twice. */
13272
+ coveredIds: external_exports.array(external_exports.string()).optional(),
13384
13273
  /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
13385
13274
  * line went out on 2026-07-28 and the caller could not answer it at all. */
13386
13275
  info: external_exports.array(external_exports.string().min(1)).max(3).default([]),
@@ -13414,6 +13303,7 @@ var AgendaTurnSchema2 = external_exports.object({
13414
13303
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
13415
13304
  blocking: external_exports.boolean().optional()
13416
13305
  });
13306
+ var CLAIM_STALE_MS2 = 30 * 6e4;
13417
13307
  var InboxItemSchema2 = external_exports.object({
13418
13308
  id: external_exports.string(),
13419
13309
  /** The conversation thread + connection this item lives on. Present on the replied
@@ -13433,6 +13323,15 @@ var InboxItemSchema2 = external_exports.object({
13433
13323
  * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
13434
13324
  * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
13435
13325
  seq: external_exports.number().int().optional(),
13326
+ /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
13327
+ * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
13328
+ * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
13329
+ * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
13330
+ * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
13331
+ * server that can still see every row states it. Absent on any row with no `askId`: a
13332
+ * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
13333
+ * the one part of its own arrival a single row cannot answer. */
13334
+ units: external_exports.number().int().positive().optional(),
13436
13335
  tokenId: external_exports.string().optional(),
13437
13336
  status: NotifyStatusSchema2,
13438
13337
  context: ContextSchema2,
@@ -13453,6 +13352,16 @@ var InboxItemSchema2 = external_exports.object({
13453
13352
  * while the party called the same dead claim stalled. Absent = no token/no data,
13454
13353
  * which must never CLAIM stalled. */
13455
13354
  lastSeenAt: external_exports.string().optional(),
13355
+ /** WHEN THE AGENT LAST SAID ANYTHING ABOUT THIS CLAIM — the newest `agent_state` row in
13356
+ * the `notification_events` ledger (trigger-written since 20260621010000, so every row a
13357
+ * user can see has one). The age input for `CLAIM_STALE_MS`, and it has to be this rather
13358
+ * than `createdAt`: a claim is very often picked up long after the row was born — the
13359
+ * inbox keeps an ANSWERED row visible while the agent works the follow-up, so a question
13360
+ * asked this morning and claimed a minute ago is eight hours old and one minute into its
13361
+ * work. Reading the row's birth as the claim's age brands that "No update in 8h" the
13362
+ * instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
13363
+ * `createdAt`. */
13364
+ agentStateAt: external_exports.string().datetime().optional(),
13456
13365
  agenda: external_exports.array(AgendaTurnSchema2).optional(),
13457
13366
  /** On a replied detail (#397): the next steps the user attached to the answer
13458
13367
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -13524,11 +13433,23 @@ var SnoozeRequestSchema2 = external_exports.object({
13524
13433
  requestId: external_exports.string(),
13525
13434
  until: external_exports.string().datetime()
13526
13435
  });
13436
+ var APNS_TOKEN_RE2 = /^[0-9a-fA-F]{64}$/;
13527
13437
  var PushTokenSchema2 = external_exports.object({
13528
13438
  voipToken: external_exports.string().min(1).optional(),
13529
13439
  alertToken: external_exports.string().min(1).optional(),
13530
13440
  fcmToken: external_exports.string().min(1).optional(),
13531
13441
  platform: external_exports.enum(["ios", "android"])
13442
+ }).superRefine((v, ctx) => {
13443
+ if (v.platform !== "ios") return;
13444
+ for (const field of ["voipToken", "alertToken"]) {
13445
+ const token = v[field];
13446
+ if (token === void 0 || APNS_TOKEN_RE2.test(token)) continue;
13447
+ ctx.addIssue({
13448
+ code: external_exports.ZodIssueCode.custom,
13449
+ path: [field],
13450
+ message: `not an APNs device token (want 64 hex chars, got ${token.length})`
13451
+ });
13452
+ }
13532
13453
  });
13533
13454
  var MissedCallSchema2 = external_exports.enum([
13534
13455
  "retry_10m",
@@ -13579,6 +13500,15 @@ var UserSettingsSchema2 = external_exports.object({
13579
13500
  /** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
13580
13501
  * defaulted — an older client PATCHing the full object must not clobber it. */
13581
13502
  pstnCalls: external_exports.boolean().optional(),
13503
+ /** The user's IANA timezone (e.g. "America/Bogota"), recorded by the app — it is the
13504
+ * only party that knows it. REMINDERS are why it exists: "remind me at ten" becomes
13505
+ * an absolute `due_at` only if we know whose ten. Optional and never defaulted, for
13506
+ * the same reason `voiceMode` is (a stale client PATCHing the whole object must not
13507
+ * clobber it) and one more: a GUESSED timezone schedules reminders hours off, and
13508
+ * that failure reads as the reminder rail being unreliable rather than as a missing
13509
+ * setting. Absent = a spoken time can't be landed, so the reminder rides the next
13510
+ * call — honest about what we know. */
13511
+ timezone: external_exports.string().min(1).max(64).optional(),
13582
13512
  /** Account E2EE state (text lane): 'off' (default) = today's plaintext; 'on' =
13583
13513
  * content is sealed end-to-end between the local agent and the phone. Like
13584
13514
  * voiceMode, OPTIONAL and NOT defaulted so a stale client PATCHing the full
@@ -13604,6 +13534,15 @@ var HistoryItemSchema2 = external_exports.object({
13604
13534
  /** When you answered the agent's notification (agent→user only). */
13605
13535
  humanAckedAt: external_exports.string().nullable()
13606
13536
  });
13537
+ var ACTIVITY_LINES2 = 2;
13538
+ var ACTIVITY_LINE_MAX2 = 80;
13539
+ var AgentActivitySchema2 = external_exports.object({
13540
+ /** Oldest first, so the newest line is last — the one that replaces in place. */
13541
+ lines: external_exports.array(external_exports.string().max(ACTIVITY_LINE_MAX2)).max(ACTIVITY_LINES2),
13542
+ /** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
13543
+ * that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
13544
+ at: external_exports.string().datetime()
13545
+ });
13607
13546
  var ConnectionSummarySchema2 = external_exports.object({
13608
13547
  /** The connection = the agent's token id (used to address a request). */
13609
13548
  id: external_exports.string(),
@@ -13637,6 +13576,11 @@ var ConnectionSummarySchema2 = external_exports.object({
13637
13576
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
13638
13577
  workspaces: external_exports.array(external_exports.string()).optional()
13639
13578
  }).optional(),
13579
+ /** The tail of this agent's working log, when a harness is driving it — the agent page's
13580
+ * live strip. Absent for anything the desktop harness isn't running (a hatched identity
13581
+ * used straight from a terminal emits no work events; the page says so rather than
13582
+ * drawing an empty box). */
13583
+ activity: AgentActivitySchema2.optional(),
13640
13584
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
13641
13585
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
13642
13586
  managed: external_exports.boolean()
@@ -13705,7 +13649,8 @@ var HandoffSchema2 = external_exports.object({
13705
13649
  recap: external_exports.boolean().optional()
13706
13650
  });
13707
13651
  var NoteSourceSchema2 = external_exports.enum(["app", "call"]);
13708
- var NoteStatusSchema2 = external_exports.enum(["open", "assigned", "done"]);
13652
+ var NoteStatusSchema2 = external_exports.enum(["open", "assigned", "in_progress", "done"]);
13653
+ var NoteRepeatSchema2 = external_exports.enum(["once", "until_done"]);
13709
13654
  var DecisionSchema2 = external_exports.object({
13710
13655
  id: external_exports.string(),
13711
13656
  /** The note this decision refines; null = recorded on a bare thread (the
@@ -13730,11 +13675,29 @@ var NoteSchema2 = external_exports.object({
13730
13675
  assignee: external_exports.string().nullable(),
13731
13676
  /** The request thread minted at assignment; null until assigned. */
13732
13677
  parentId: external_exports.string().nullable(),
13678
+ /** REMINDERS (reminders-design.md): the NOT-BEFORE this becomes eligible to ride a
13679
+ * call — never a deadline, and nothing rings when it passes. Null = "the very next
13680
+ * call", the right reading of "remind me to…" with no time attached. */
13681
+ // Defaulted, not required: a Note from an API deploy older than the reminders
13682
+ // migration has none of these, and the defaults ARE what it means — no not-before,
13683
+ // one ride, never ridden. Parsing must not fail across a rolling deploy.
13684
+ dueAt: external_exports.string().nullable().default(null),
13685
+ repeat: NoteRepeatSchema2.default("once"),
13686
+ /** How many calls have already carried it — the fatigue cap counts rides, not days. */
13687
+ rides: external_exports.number().int().default(0),
13688
+ lastRideAt: external_exports.string().nullable().default(null),
13733
13689
  createdAt: external_exports.string()
13734
13690
  });
13735
13691
  var CreateNoteSchema2 = external_exports.object({
13736
13692
  /** The intent, in the user's own words. Stored verbatim; the broker only titles it. */
13737
- text: external_exports.string().min(1).max(4e3)
13693
+ text: external_exports.string().min(1).max(4e3),
13694
+ /** Capture it as a REMINDER — a note assigned to the user themselves, which rides
13695
+ * their next call instead of being handed to an agent. Everything else about the
13696
+ * note is identical; this is the one parameter that separates the two. */
13697
+ forMe: external_exports.boolean().optional(),
13698
+ /** The not-before, when the user already said one. Absent = the very next call. */
13699
+ dueAt: external_exports.string().datetime().optional(),
13700
+ repeat: NoteRepeatSchema2.optional()
13738
13701
  });
13739
13702
  var RecordDecisionSchema2 = external_exports.object({
13740
13703
  /** An open decision (from /clarify) to answer. */
@@ -13940,6 +13903,323 @@ var FeedbackOutcomeSchema2 = external_exports.object({
13940
13903
  childIds: external_exports.array(external_exports.string()).optional()
13941
13904
  });
13942
13905
 
13906
+ // src/paigy/activity.ts
13907
+ function shortenPaths(s) {
13908
+ return s.replace(/(?<![\w.@+-])(?:\/[\w.@+-]+){3,}/g, (p) => {
13909
+ const parts = p.split("/").filter(Boolean);
13910
+ return `\u2026/${parts.slice(-2).join("/")}`;
13911
+ });
13912
+ }
13913
+ function workLine(event) {
13914
+ const note = (event.note ?? "").split("\n").find((l) => l.trim()) ?? "";
13915
+ const line = shortenPaths([event.tool.trim(), note.trim()].filter(Boolean).join(" \u2014 ").replace(/\s+/g, " "));
13916
+ return line.length > ACTIVITY_LINE_MAX2 ? `${line.slice(0, ACTIVITY_LINE_MAX2 - 1)}\u2026` : line;
13917
+ }
13918
+ function pushWork(lines, line) {
13919
+ if (!line || lines[lines.length - 1] === line) return [...lines];
13920
+ return [...lines, line].slice(-ACTIVITY_LINES2);
13921
+ }
13922
+ function sameTail(a, b) {
13923
+ return a.length === b.length && a.every((l, i) => l === b[i]);
13924
+ }
13925
+
13926
+ // src/harness/session.ts
13927
+ import { spawn } from "child_process";
13928
+ import { existsSync as existsSync4 } from "fs";
13929
+ import { homedir as homedir4 } from "os";
13930
+ import { resolve as resolve2, delimiter as delimiter2 } from "path";
13931
+
13932
+ // src/harness/acp.ts
13933
+ var none = { events: [], writes: [] };
13934
+ function optionFor(options, decision) {
13935
+ const want = decision.allow ? "allow_once" : "reject_once";
13936
+ return options.find((o) => o.kind === want)?.optionId ?? null;
13937
+ }
13938
+ function createAcpDriver(opts) {
13939
+ return new AcpDriver(opts.cwd, opts.mode, opts.mcp ?? []);
13940
+ }
13941
+ var AcpDriver = class {
13942
+ constructor(cwd, mode, mcp = []) {
13943
+ this.cwd = cwd;
13944
+ this.mode = mode;
13945
+ this.mcp = mcp;
13946
+ }
13947
+ cwd;
13948
+ mode;
13949
+ mcp;
13950
+ nextId = 1;
13951
+ initId;
13952
+ sessionNewId;
13953
+ promptId;
13954
+ sessionId;
13955
+ queued = [];
13956
+ /** Options of each unanswered permission request, keyed by its JSON-RPC id. */
13957
+ pending = /* @__PURE__ */ new Map();
13958
+ /** Where the REPORT starts in `text` — everything before the LAST tool call is working
13959
+ * narration ("Now the API endpoints." → runs a tool), and it used to ship: the chunks
13960
+ * concatenate with no separator, so the owner's phone got "…find the repo.Now I have
13961
+ * the full picture. Writing the migration.Now…" as the opening paragraph of a finished
13962
+ * task (live, 2026-08-11 — "looks like a working log"). The narration's audience is the
13963
+ * terminal and host.log; what the agent composed AFTER its last tool call is the part
13964
+ * addressed to a human, and that is what leaves the machine. */
13965
+ reportFrom = 0;
13966
+ /** The turn being streamed: text accumulates, tools append, both flush on stopReason. */
13967
+ text = "";
13968
+ tools = [];
13969
+ /** The opening frame. Everything after is driven by responses in `handleLine`. */
13970
+ open() {
13971
+ this.initId = this.nextId++;
13972
+ return [
13973
+ frame({
13974
+ id: this.initId,
13975
+ method: "initialize",
13976
+ params: {
13977
+ protocolVersion: 2,
13978
+ // We are not an editor: no file services offered, the agent uses its own.
13979
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
13980
+ clientInfo: { name: "paigy-desktop", version: "0.0.0" }
13981
+ }
13982
+ })
13983
+ ];
13984
+ }
13985
+ /** Queue a prompt; it goes out when the session exists and no turn is running. */
13986
+ send(text) {
13987
+ this.queued.push(text);
13988
+ return this.flush();
13989
+ }
13990
+ /** Answer a PermissionEvent (ask mode). The id is the request's JSON-RPC id as a string. */
13991
+ respond(id, decision) {
13992
+ const request = this.pending.get(id);
13993
+ if (!request) return [];
13994
+ this.pending.delete(id);
13995
+ const optionId = optionFor(request.options, decision);
13996
+ return [
13997
+ optionId ? frame({ id: request.id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id: request.id, result: { outcome: { outcome: "cancelled" } } })
13998
+ ];
13999
+ }
14000
+ /** Cancel anything still blocked — called on stop so the process can exit cleanly. */
14001
+ close() {
14002
+ const writes = [...this.pending.values()].map(
14003
+ (r) => frame({ id: r.id, result: { outcome: { outcome: "cancelled" } } })
14004
+ );
14005
+ this.pending.clear();
14006
+ return writes;
14007
+ }
14008
+ handleLine(line) {
14009
+ const trimmed = line.trim();
14010
+ if (!trimmed) return none;
14011
+ let msg;
14012
+ try {
14013
+ msg = JSON.parse(trimmed);
14014
+ } catch {
14015
+ return none;
14016
+ }
14017
+ if (msg.method !== void 0) {
14018
+ return msg.id !== void 0 ? this.handleRequest(msg) : this.handleNotification(msg);
14019
+ }
14020
+ if (msg.id !== void 0) return this.handleResponse(msg);
14021
+ return none;
14022
+ }
14023
+ // ── responses to our requests ──
14024
+ handleResponse(msg) {
14025
+ if (msg.id === this.initId) {
14026
+ this.initId = void 0;
14027
+ if (msg.error) {
14028
+ return { events: [{ kind: "error", message: `initialize failed: ${msg.error.message}` }], writes: [] };
14029
+ }
14030
+ this.sessionNewId = this.nextId++;
14031
+ return {
14032
+ events: [],
14033
+ writes: [frame({ id: this.sessionNewId, method: "session/new", params: { cwd: this.cwd, mcpServers: this.mcp } })]
14034
+ };
14035
+ }
14036
+ if (msg.id === this.sessionNewId) {
14037
+ this.sessionNewId = void 0;
14038
+ const sessionId = msg.result?.sessionId;
14039
+ if (!sessionId) {
14040
+ return {
14041
+ events: [{ kind: "error", message: `session/new failed: ${msg.error?.message ?? "no sessionId"}` }],
14042
+ writes: []
14043
+ };
14044
+ }
14045
+ this.sessionId = sessionId;
14046
+ const value = this.mode === "bypass" ? "bypassPermissions" : "default";
14047
+ const writes = [
14048
+ frame({
14049
+ id: this.nextId++,
14050
+ method: "session/set_config_option",
14051
+ params: { sessionId, configId: "mode", value }
14052
+ }),
14053
+ frame({ id: this.nextId++, method: "session/set_mode", params: { sessionId, modeId: value } })
14054
+ ];
14055
+ writes.push(...this.flush());
14056
+ return { events: [], writes };
14057
+ }
14058
+ if (msg.id === this.promptId) {
14059
+ this.promptId = void 0;
14060
+ const events = [];
14061
+ const report = this.text.slice(this.reportFrom).trim() || this.text.trim();
14062
+ if (report || this.tools.length) {
14063
+ events.push({
14064
+ kind: "turn",
14065
+ role: "agent",
14066
+ text: report,
14067
+ ...this.tools.length ? { tools: [...this.tools] } : {}
14068
+ });
14069
+ }
14070
+ const result = report;
14071
+ const failed = msg.error !== void 0 || msg.result?.stopReason === "refusal";
14072
+ this.text = "";
14073
+ this.tools = [];
14074
+ this.reportFrom = 0;
14075
+ const writes = this.flush();
14076
+ if (!writes.length) {
14077
+ events.push({
14078
+ kind: "idle",
14079
+ ...result ? { result } : {},
14080
+ ...failed ? { failed: true } : {}
14081
+ });
14082
+ }
14083
+ return { events, writes };
14084
+ }
14085
+ return none;
14086
+ }
14087
+ // ── the agent talking to us ──
14088
+ handleNotification(msg) {
14089
+ if (msg.method !== "session/update") return none;
14090
+ const update = msg.params?.update;
14091
+ switch (update?.sessionUpdate) {
14092
+ case "agent_message_chunk":
14093
+ this.text += update.content?.text ?? "";
14094
+ return none;
14095
+ case "tool_call": {
14096
+ const title = update.title?.trim() || update.kind || "tool";
14097
+ this.tools.push(title);
14098
+ const note = this.text.slice(this.reportFrom).trim();
14099
+ this.reportFrom = this.text.length;
14100
+ return { events: [{ kind: "work", tool: title, ...note ? { note } : {} }], writes: [] };
14101
+ }
14102
+ default:
14103
+ return none;
14104
+ }
14105
+ }
14106
+ handleRequest(msg) {
14107
+ if (msg.method !== "session/request_permission") {
14108
+ return {
14109
+ events: [],
14110
+ writes: [frame({ id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } })]
14111
+ };
14112
+ }
14113
+ const id = msg.id;
14114
+ const options = msg.params?.options ?? [];
14115
+ const title = msg.params?.toolCall?.title?.trim() || "a tool call";
14116
+ const tool = msg.params?.toolCall?.kind ?? "tool";
14117
+ if (this.mode === "bypass") {
14118
+ const optionId = optionFor(options, { allow: true }) ?? optionFor(options, { allow: false });
14119
+ return {
14120
+ // The decision still goes through Paigy — as history, not a question. Bypass
14121
+ // means "don't stall the agent", never "don't tell the user".
14122
+ events: [{ kind: "turn", role: "agent", text: `auto-approved: ${title}`, tools: [tool] }],
14123
+ writes: [
14124
+ optionId ? frame({ id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id, result: { outcome: { outcome: "cancelled" } } })
14125
+ ]
14126
+ };
14127
+ }
14128
+ this.pending.set(String(id), { id, options });
14129
+ return {
14130
+ events: [{ kind: "permission", id: String(id), tool, summary: title }],
14131
+ writes: []
14132
+ };
14133
+ }
14134
+ /** Send the queued prompts as one turn, if the agent can take one right now. */
14135
+ flush() {
14136
+ if (!this.sessionId || this.promptId !== void 0 || !this.queued.length) return [];
14137
+ const blocks = this.queued.map((text) => ({ type: "text", text }));
14138
+ this.queued = [];
14139
+ this.promptId = this.nextId++;
14140
+ return [
14141
+ frame({
14142
+ id: this.promptId,
14143
+ method: "session/prompt",
14144
+ params: { sessionId: this.sessionId, prompt: blocks }
14145
+ })
14146
+ ];
14147
+ }
14148
+ };
14149
+ var frame = (body) => JSON.stringify({ jsonrpc: "2.0", ...body });
14150
+
14151
+ // src/harness/session.ts
14152
+ var ADAPTER_BIN = {
14153
+ claude: "claude-agent-acp",
14154
+ codex: "codex-acp",
14155
+ agy: "agy"
14156
+ };
14157
+ function splitLines(buffer, chunk) {
14158
+ const combined = buffer + chunk;
14159
+ const parts = combined.split("\n");
14160
+ const rest = parts.pop() ?? "";
14161
+ return { lines: parts.filter((l) => l.trim()), rest };
14162
+ }
14163
+ function startSession(opts) {
14164
+ const cwd = resolve2(opts.cwd.replace(/^~(?=$|\/)/, homedir4()));
14165
+ if (!existsSync4(cwd)) {
14166
+ queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
14167
+ }
14168
+ const child = (opts.spawnFn ?? spawn)(opts.bin ?? ADAPTER_BIN[opts.harness], [], {
14169
+ cwd,
14170
+ // The adapter shells out to its vendor CLI (`claude`, `codex`), and a GUI- or
14171
+ // launchd-launched process's PATH won't have it — probe dirs plus the RUNNING
14172
+ // node's own bin dir (nvm installs the adapters next to node; launchd's bare
14173
+ // PATH knows neither — live catch 2026-08-04: the first slot-wake ENOENT'd).
14174
+ env: {
14175
+ ...process.env,
14176
+ PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
14177
+ ...opts.token ? { PAIGY_TOKEN: opts.token } : {}
14178
+ },
14179
+ stdio: ["pipe", "pipe", "pipe"]
14180
+ });
14181
+ const write = (frames) => {
14182
+ for (const f of frames) child.stdin?.write(`${f}
14183
+ `);
14184
+ };
14185
+ child.stderr?.on("data", (chunk) => {
14186
+ const text = String(chunk).trim();
14187
+ if (text) opts.onEvent({ kind: "error", message: text });
14188
+ });
14189
+ child.on("exit", (code) => {
14190
+ opts.onEvent({ kind: "idle", failed: code !== 0, ...code ? { result: `exited ${code}` } : {} });
14191
+ opts.onExit?.();
14192
+ });
14193
+ child.on("error", (e) => {
14194
+ opts.onEvent({ kind: "error", message: e.message });
14195
+ opts.onExit?.();
14196
+ });
14197
+ const driver = createAcpDriver({ cwd, mode: opts.mode, ...opts.mcp ? { mcp: opts.mcp } : {} });
14198
+ let buffer = "";
14199
+ child.stdout?.on("data", (chunk) => {
14200
+ const { lines, rest } = splitLines(buffer, String(chunk));
14201
+ buffer = rest;
14202
+ for (const line of lines) {
14203
+ const { events, writes } = driver.handleLine(line);
14204
+ write(writes);
14205
+ for (const event of events) opts.onEvent(event);
14206
+ }
14207
+ });
14208
+ write(driver.open());
14209
+ return {
14210
+ send(text) {
14211
+ write(driver.send(text));
14212
+ },
14213
+ respond(id, decision) {
14214
+ write(driver.respond(id, decision));
14215
+ },
14216
+ stop() {
14217
+ write(driver.close());
14218
+ child.kill();
14219
+ }
14220
+ };
14221
+ }
14222
+
13943
14223
  // src/paigy/conversation.ts
13944
14224
  function endsWithQuestion(text) {
13945
14225
  if (!text) return false;
@@ -14205,6 +14485,7 @@ var clip = (s) => (s.length > 120 ? `${s.slice(0, 117)}\u2026` : s) || "(no text
14205
14485
  // src/run.ts
14206
14486
  function runHarness(opts) {
14207
14487
  let running = true;
14488
+ let tail = [];
14208
14489
  const state = { ...opts.exclusive ? { exclusive: true } : {} };
14209
14490
  let session = null;
14210
14491
  const asMe = { token: opts.token };
@@ -14234,6 +14515,7 @@ function runHarness(opts) {
14234
14515
  }
14235
14516
  if (event.kind === "work") {
14236
14517
  opts.log(`\u2699 ${event.tool}${event.note ? ` \u2014 ${event.note.split("\n")[0] ?? ""}` : ""}`);
14518
+ tail = pushWork(tail, workLine(event));
14237
14519
  return;
14238
14520
  }
14239
14521
  const { parentId } = await mirror(event, state, deps);
@@ -14241,6 +14523,7 @@ function runHarness(opts) {
14241
14523
  if (event.kind === "turn") opts.log(`${event.role}: ${event.text.split("\n")[0] ?? ""}`);
14242
14524
  if (event.kind === "idle") {
14243
14525
  state.resting = true;
14526
+ tail = [];
14244
14527
  if (endsWithQuestion(event.result)) {
14245
14528
  const local = await opts.localAsk?.question?.(event.result ?? "") ?? null;
14246
14529
  if (local?.trim() && session && running) {
@@ -14278,8 +14561,10 @@ function runHarness(opts) {
14278
14561
  session?.send(text);
14279
14562
  },
14280
14563
  working: () => running && state.resting !== true,
14564
+ tail: () => [...tail],
14281
14565
  stop() {
14282
14566
  running = false;
14567
+ tail = [];
14283
14568
  cancelAsks(state);
14284
14569
  session?.stop();
14285
14570
  session = null;
@@ -14308,6 +14593,12 @@ function hostElsewhere() {
14308
14593
  const s = readHostState();
14309
14594
  return !!s && s.pid !== process.pid;
14310
14595
  }
14596
+ var IDLE_WAKE_MS = 10 * 6e4;
14597
+ function wakeAction(live, seenAt, now) {
14598
+ if (!live) return "spawn";
14599
+ if (seenAt === void 0) return "skip";
14600
+ return now - seenAt >= IDLE_WAKE_MS ? "prompt" : "skip";
14601
+ }
14311
14602
  function startHost(opts) {
14312
14603
  const runs = /* @__PURE__ */ new Map();
14313
14604
  const asHost = { token: opts.token };
@@ -14363,12 +14654,15 @@ function startHost(opts) {
14363
14654
  }
14364
14655
  }
14365
14656
  const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex", antigravity: "agy" };
14366
- const WAKE_PROMPT = "You were woken because Paigy work is waiting for you. This is a fresh session with your existing identity, so you may already have work in flight that you can't remember. Call check_replies FIRST, then get_thread on each conversation it returns and read it before you touch anything: it holds what you were doing, where (a repo or worktree may not be this folder), and what the owner already decided. Then handle what's waiting and follow your paigy instructions to stay in the loop. Speak to the owner at exactly TWO moments: when you are BLOCKED on a decision only they can make (contact with waiting:'hard', the decision as the ask, options if you have real ones), and when you are DONE (one short report: what shipped, how you verified it, anything you flagged). Progress is never a contact \u2014 call set_task_state('in_progress') when you pick work up and the app shows you working; narrate to the terminal, not to the human.";
14657
+ const wakePrompt = (fresh) => "You were woken because Paigy work is waiting for you. " + (fresh ? "This is a fresh session with your existing identity, so you may already have work in flight that you can't remember. " : "You have been running, so some of this may already be yours \u2014 but not what arrived while you were idle. ") + "Call check_replies FIRST, then get_thread on each conversation it returns and read it before you touch anything: it holds what you were doing, where (a repo or worktree may not be this folder), and what the owner already decided. Then handle what's waiting and follow your paigy instructions to stay in the loop. Speak to the owner at exactly TWO moments: when you are BLOCKED on a decision only they can make (contact with waiting:'hard', the decision as the ask, options if you have real ones), and when you are DONE (one short report: what shipped, how you verified it, anything you flagged). Progress is never a contact \u2014 call set_task_state('in_progress') when you pick work up and the app shows you working; narrate to the terminal, not to the human.";
14367
14658
  async function sweepSlots() {
14368
14659
  for (const slot of listSlots()) {
14369
14660
  const harness = SLOT_HARNESS[slot] ?? (slot === "Desktop" ? void 0 : "claude");
14370
14661
  const key = `slot:${slot}`;
14371
- if (!harness || runs.has(key)) continue;
14662
+ if (!harness) continue;
14663
+ const live = runs.get(key);
14664
+ const action = wakeAction(!!live, published.get(key)?.movedAt, Date.now());
14665
+ if (action === "skip") continue;
14372
14666
  const token = readToken(slot);
14373
14667
  if (!token) continue;
14374
14668
  const workspace = resolveWakeDir(slotIdentity(slot).workspace, opts.wsDeps);
@@ -14376,12 +14670,20 @@ function startHost(opts) {
14376
14670
  const work = await checkReplies({ token }).catch(() => null);
14377
14671
  if (!work || work.requests.length === 0 && work.replies.length === 0) continue;
14378
14672
  const label = slotName(slot) ?? slot;
14673
+ if (live) {
14674
+ const seen = published.get(key);
14675
+ const quiet = seen ? Math.round((Date.now() - seen.movedAt) / 6e4) : 0;
14676
+ live.run.send(wakePrompt(false));
14677
+ if (seen) seen.movedAt = Date.now();
14678
+ opts.log(`\u25B6 re-woke ${label} \u2014 quiet ${quiet}m with work waiting`);
14679
+ continue;
14680
+ }
14379
14681
  const log2 = (line) => opts.log(`[${label}] ${line}`);
14380
14682
  const run = runHarness({
14381
14683
  harness,
14382
14684
  cwd: workspace,
14383
14685
  mode: "bypass",
14384
- prompt: WAKE_PROMPT,
14686
+ prompt: wakePrompt(true),
14385
14687
  exclusive: true,
14386
14688
  token,
14387
14689
  // A dead run must not squat the slot — evict so the next wake can respawn.
@@ -14397,6 +14699,27 @@ function startHost(opts) {
14397
14699
  opts.log(`\u25B6 woke ${label} (${harness}) \u2014 work was waiting in ${workspace}`);
14398
14700
  }
14399
14701
  }
14702
+ const ACTIVITY_MS = 2e3;
14703
+ const published = /* @__PURE__ */ new Map();
14704
+ const streamActivity = () => {
14705
+ const publishTail = (token, lines) => {
14706
+ void heartbeat(void 0, { token, activity: { lines, at: (/* @__PURE__ */ new Date()).toISOString() } }).catch(() => {
14707
+ });
14708
+ };
14709
+ for (const [key, r] of runs) {
14710
+ if (!r.token) continue;
14711
+ const lines = r.run.tail();
14712
+ const was = published.get(key);
14713
+ if (was && sameTail(was.lines, lines)) continue;
14714
+ published.set(key, { token: r.token, lines, movedAt: Date.now() });
14715
+ publishTail(r.token, lines);
14716
+ }
14717
+ for (const [key, was] of published) {
14718
+ if (runs.has(key)) continue;
14719
+ published.delete(key);
14720
+ if (was.lines.length > 0) publishTail(was.token, []);
14721
+ }
14722
+ };
14400
14723
  const publish = () => {
14401
14724
  try {
14402
14725
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: Date.now(), roster: api.roster() }));
@@ -14410,6 +14733,7 @@ function startHost(opts) {
14410
14733
  void sweepSlots();
14411
14734
  publish();
14412
14735
  }, 5e3);
14736
+ const activityTick = setInterval(streamActivity, ACTIVITY_MS);
14413
14737
  const stopSessions = () => {
14414
14738
  for (const { run, label } of runs.values()) {
14415
14739
  run.stop();
@@ -14447,6 +14771,8 @@ function startHost(opts) {
14447
14771
  clearInterval(pulse);
14448
14772
  clearInterval(spawnPoll);
14449
14773
  stopSessions();
14774
+ streamActivity();
14775
+ clearInterval(activityTick);
14450
14776
  try {
14451
14777
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: 0, roster: [] }));
14452
14778
  } catch {