@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/cli.js CHANGED
@@ -23175,6 +23175,79 @@ function detectAll(deps = {}) {
23175
23175
  return CATALOG.map((entry) => detect(entry, deps));
23176
23176
  }
23177
23177
 
23178
+ // src/cli-args.ts
23179
+ var USAGE = "usage: paigy-harness setup | pair | host [--grant DIR]\u2026 | service | enable-tools [--scope user|project] | hatch NAME [--voice KEY] [--slot SLOT] | handoff | [--harness claude|codex|agy] [--mode bypass|ask] [--cwd DIR] [--identity NAME] [--grace SECONDS] [--doctor] PROMPT\u2026";
23180
+ function parseArgs(argv) {
23181
+ const args = { harness: "claude", mode: "bypass", cwd: process.cwd(), doctor: false, help: false, host: false, pair: false, service: false, setup: false, enableTools: false, scope: "user", grant: [], hatch: null, voice: null, slot: null, identity: null, graceSeconds: 90, prompt: "", handoff: false };
23182
+ const words = [];
23183
+ for (let i = 0; i < argv.length; i++) {
23184
+ const arg = argv[i];
23185
+ const next = () => argv[++i];
23186
+ if (arg === "--harness") {
23187
+ const v = next();
23188
+ if (v !== "claude" && v !== "codex" && v !== "agy") return { error: `--harness must be claude, codex, or agy, got ${v ?? "nothing"}` };
23189
+ args.harness = v;
23190
+ } else if (arg === "--mode") {
23191
+ const v = next();
23192
+ if (v !== "bypass" && v !== "ask") return { error: `--mode must be bypass or ask, got ${v ?? "nothing"}` };
23193
+ args.mode = v;
23194
+ } else if (arg === "--cwd") {
23195
+ const v = next();
23196
+ if (!v) return { error: "--cwd needs a directory" };
23197
+ args.cwd = v;
23198
+ } else if (arg === "--doctor") {
23199
+ args.doctor = true;
23200
+ } else if (arg === "-h" || arg === "--help" || arg === "help") {
23201
+ args.help = true;
23202
+ } else if (arg === "--scope") {
23203
+ const v = next();
23204
+ if (v !== "user" && v !== "project") return { error: `--scope must be user or project, got ${v ?? "nothing"}` };
23205
+ args.scope = v;
23206
+ } else if (arg === "--grace") {
23207
+ const v = Number(next());
23208
+ if (!Number.isFinite(v) || v < 0) return { error: "--grace needs seconds (0 = straight to phone)" };
23209
+ args.graceSeconds = v;
23210
+ } else if (arg === "host") {
23211
+ args.host = true;
23212
+ } else if (arg === "pair") {
23213
+ args.pair = true;
23214
+ } else if (arg === "setup") {
23215
+ args.setup = true;
23216
+ } else if (arg === "service") {
23217
+ args.service = true;
23218
+ } else if (arg === "--grant") {
23219
+ const v = next();
23220
+ if (!v) return { error: "--grant needs a folder to allow" };
23221
+ args.grant.push(v);
23222
+ } else if (arg === "handoff") {
23223
+ args.handoff = true;
23224
+ } else if (arg === "enable-tools") {
23225
+ args.enableTools = true;
23226
+ } else if (arg === "hatch") {
23227
+ const v = next();
23228
+ if (!v) return { error: 'hatch needs a name: paigy-harness hatch "Voice bug hunter"' };
23229
+ args.hatch = v;
23230
+ } else if (arg === "--voice") {
23231
+ args.voice = next() ?? null;
23232
+ } else if (arg === "--slot") {
23233
+ args.slot = next() ?? null;
23234
+ } else if (arg === "--identity") {
23235
+ const v = next();
23236
+ if (!v) return { error: "--identity needs a hatched agent's name" };
23237
+ args.identity = v;
23238
+ } else if (arg?.startsWith("--")) {
23239
+ return { error: `unknown flag ${arg}` };
23240
+ } else if (arg) {
23241
+ words.push(arg);
23242
+ }
23243
+ }
23244
+ args.prompt = words.join(" ");
23245
+ if (args.help) return args;
23246
+ if (!args.doctor && !args.handoff && !args.hatch && !args.host && !args.pair && !args.service && !args.setup && !args.enableTools && !args.prompt) return { error: "a prompt is required (or setup / --doctor / pair / host / service / enable-tools / hatch NAME / handoff)" };
23247
+ return args;
23248
+ }
23249
+ var ENABLE_TOOLS_COMMAND = "npx -y -p @paigy/mcp@latest paigy-enable-tools";
23250
+
23178
23251
  // ../../packages/sdk/dist/index.js
23179
23252
  import { createRequire as __sdkCreateRequire } from "module";
23180
23253
  import { randomUUID } from "crypto";
@@ -29553,6 +29626,12 @@ var ReceiptEventSchema = external_exports.enum([
29553
29626
  // the recipient opened it
29554
29627
  "answered",
29555
29628
  // the recipient replied
29629
+ // The recipient TURNED THE RING DOWN — CallKit ended it and no answer was ever tapped.
29630
+ // Written by the phone, on the same door that reports the ring itself, so it exists only
29631
+ // when a ring reached a running app and a person did not take it. That is what separates
29632
+ // it from "a ring with no answer", which our own crashes wrote just as readily and which
29633
+ // is why the responsiveness back-off had to be removed (#1144).
29634
+ "declined",
29556
29635
  "escalated",
29557
29636
  // re-reached at a higher level (re-ring / promote)
29558
29637
  "coalesced",
@@ -29561,8 +29640,14 @@ var ReceiptEventSchema = external_exports.enum([
29561
29640
  // deadline passed unanswered
29562
29641
  "woke",
29563
29642
  // the agent was woken for an owed obligation (callback)
29564
- "gave_up"
29643
+ "gave_up",
29565
29644
  // the budget was spent — stopped re-engaging
29645
+ // The ladder starts over — a silent pickup (the owner's fresh-miss rule, 2026-07-28), a
29646
+ // promote to call, a re-delivery. APPENDED, never a rewind: `ring_step` was a cache of
29647
+ // the escalated-count and a writer rewound it to 0 on every unanswered call (2026-08-24,
29648
+ // nine rings in an hour, `gaveUp` unreachable). A count since the last `restarted` cannot
29649
+ // be rewound by a writer that forgot to advance it.
29650
+ "restarted"
29566
29651
  ]);
29567
29652
  var AttentionSchema = external_exports.object({
29568
29653
  urgency: NotifyLevelSchema,
@@ -29694,7 +29779,7 @@ var NotifyRequestSchema = external_exports.object({
29694
29779
  if (r.ask !== void 0) {
29695
29780
  for (const f of ["context", "select", "points"]) {
29696
29781
  if (r[f] !== void 0)
29697
- 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)` });
29782
+ 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.` });
29698
29783
  }
29699
29784
  return;
29700
29785
  }
@@ -29807,6 +29892,14 @@ var IntentSchema = external_exports.object({
29807
29892
  * about what cannot answer "is the bot looping less this week?". */
29808
29893
  fault: external_exports.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
29809
29894
  });
29895
+ var RideAlongSchema = external_exports.object({
29896
+ /** The note this came from — assign/clarify/close it through /api/notes/:id. */
29897
+ noteId: external_exports.string(),
29898
+ /** What to do, in the owner's own words (the note's headline). Never model-rewritten. */
29899
+ text: external_exports.string(),
29900
+ /** The thread to report back on, when the note was dispatched over the request rail. */
29901
+ parentId: external_exports.string().nullable()
29902
+ });
29810
29903
  var AwaitItemSchema = external_exports.discriminatedUnion("type", [
29811
29904
  external_exports.object({
29812
29905
  type: external_exports.literal("reply"),
@@ -29827,7 +29920,13 @@ var AwaitItemSchema = external_exports.discriminatedUnion("type", [
29827
29920
  transcript: external_exports.string().optional(),
29828
29921
  /** Coverage report (#396), when the ask declared `points`: which of them this
29829
29922
  * answer addressed. Missing points = re-ask or proceed knowingly partial. */
29830
- covered: external_exports.array(external_exports.string()).optional()
29923
+ covered: external_exports.array(external_exports.string()).optional(),
29924
+ /** Ride-alongs (RideAlongSchema) — pending work for you, attached to the moment you
29925
+ * became free. Only `reply` and `idle` carry it: those are the two outcomes that
29926
+ * END a wait. `remind`, `superseded` and `turn` are mid-flight, and handing an
29927
+ * agent a side-quest while it is still holding the line is how the main thing gets
29928
+ * dropped. Absent/empty = nothing owed. */
29929
+ also: external_exports.array(RideAlongSchema).optional()
29831
29930
  }),
29832
29931
  external_exports.object({
29833
29932
  type: external_exports.literal("remind"),
@@ -29862,7 +29961,7 @@ var AwaitItemSchema = external_exports.discriminatedUnion("type", [
29862
29961
  acts: external_exports.array(IntentSchema).nullable().optional()
29863
29962
  })
29864
29963
  }),
29865
- external_exports.object({ type: external_exports.literal("idle") })
29964
+ external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema).optional() })
29866
29965
  ]);
29867
29966
  var CallbackTriggerSchema = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
29868
29967
  var ScheduleCallbackSchema = external_exports.object({
@@ -29893,6 +29992,12 @@ var PendingRepliesSchema = external_exports.object({
29893
29992
  pending: external_exports.array(
29894
29993
  external_exports.object({ parentId: external_exports.string(), notificationId: external_exports.string(), createdAt: external_exports.string() })
29895
29994
  ),
29995
+ /** WHO YOU ARE on this account (field report 2026-08-28): the name and device the user
29996
+ * sees for this session's identity. From inside a session there was no way to find out —
29997
+ * `pair` with no arguments can HATCH a fresh identity, so it is not a safe probe — and an
29998
+ * agent that cannot tell which agent it is cannot tell whether work addressed to
29999
+ * "Reta" was addressed to it. Absent only for a token with no pairing behind it. */
30000
+ you: external_exports.object({ name: external_exports.string(), device: external_exports.string().nullable(), tokenId: external_exports.string() }).optional(),
29896
30001
  /** User-initiated requests addressed to this agent; act on them and reply via
29897
30002
  * contact on the same parentId. Keeps reappearing until you call
29898
30003
  * set_task_state on its notificationId. */
@@ -29904,12 +30009,21 @@ var PendingRepliesSchema = external_exports.object({
29904
30009
  createdAt: external_exports.string(),
29905
30010
  /** The user seeded this request with a past conversation — call get_thread on it
29906
30011
  * FIRST and treat the transcript as prior context (#57/#251). */
29907
- contextParentId: external_exports.string().optional()
30012
+ contextParentId: external_exports.string().optional(),
30013
+ /** STRANDED (field report 2026-08-28): this request was addressed to ANOTHER agent on
30014
+ * the account — the name here — which has not been seen since it landed, so nobody
30015
+ * came for it. Handed to you because you are the session that is here. Take it like
30016
+ * any request (set_task_state claims it, reply with contact on its parentId), and say
30017
+ * whose it was, because the user chose that agent on purpose. */
30018
+ stranded: external_exports.string().optional()
29908
30019
  })
29909
30020
  ),
29910
30021
  /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
29911
30022
  * if blocked, or at a time that has passed). Re-surfaced every sweep until you
29912
30023
  * fulfill one by calling contact on its parentId. */
30024
+ /** Ride-alongs (RideAlongSchema): notes assigned to this agent that no wake could
30025
+ * reach. Same array the contact/await replies carry — one queue, every carrier. */
30026
+ also: external_exports.array(RideAlongSchema).optional(),
29913
30027
  owedCallbacks: external_exports.array(
29914
30028
  external_exports.object({ parentId: external_exports.string(), trigger: CallbackTriggerSchema, note: external_exports.string() })
29915
30029
  ),
@@ -29945,7 +30059,11 @@ var NotifyResponseSchema = external_exports.object({
29945
30059
  status: NotifyStatusSchema,
29946
30060
  createdAt: external_exports.string().datetime(),
29947
30061
  answer: UserAnswerSchema.optional(),
29948
- answeredAt: external_exports.string().datetime().optional()
30062
+ answeredAt: external_exports.string().datetime().optional(),
30063
+ /** Ride-alongs for THIS agent — pending work it should pick up when it's done with
30064
+ * what it came for. Present on any reply, because an unwakeable agent's only
30065
+ * reliable moment is one it initiated. Absent/empty = nothing owed. */
30066
+ also: external_exports.array(RideAlongSchema).optional()
29949
30067
  });
29950
30068
  var NotifyPlanUnitSchema = external_exports.object({
29951
30069
  notificationId: external_exports.string(),
@@ -29967,7 +30085,13 @@ var NotifyPlanUnitSchema = external_exports.object({
29967
30085
  proposal: external_exports.object({
29968
30086
  select: SelectShapeSchema,
29969
30087
  options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
29970
- }).optional()
30088
+ }).optional(),
30089
+ /** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
30090
+ * between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
30091
+ * needs to know. Reported so the agent can correct a misread the same way it ratifies a
30092
+ * shape — the read RAISES (a decision always asks) and never silences a question the
30093
+ * agent declared (#731, #923). */
30094
+ wants: external_exports.enum(["decision", "approval", "knowledge"]).optional()
29971
30095
  });
29972
30096
  var NotifyPlanSchema = external_exports.object({
29973
30097
  units: external_exports.array(NotifyPlanUnitSchema),
@@ -29991,6 +30115,9 @@ var UserResponseSchema = external_exports.object({
29991
30115
  });
29992
30116
  var VoiceKeySchema = external_exports.enum(["rachel", "george", "jessica", "brian", "lily"]);
29993
30117
  var AgendaTurnSchema = external_exports.object({
30118
+ /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
30119
+ * the planner declares duplicates instead of asking them twice. */
30120
+ coveredIds: external_exports.array(external_exports.string()).optional(),
29994
30121
  /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
29995
30122
  * line went out on 2026-07-28 and the caller could not answer it at all. */
29996
30123
  info: external_exports.array(external_exports.string().min(1)).max(3).default([]),
@@ -30024,6 +30151,7 @@ var AgendaTurnSchema = external_exports.object({
30024
30151
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
30025
30152
  blocking: external_exports.boolean().optional()
30026
30153
  });
30154
+ var CLAIM_STALE_MS = 30 * 6e4;
30027
30155
  var InboxItemSchema = external_exports.object({
30028
30156
  id: external_exports.string(),
30029
30157
  /** The conversation thread + connection this item lives on. Present on the replied
@@ -30043,6 +30171,15 @@ var InboxItemSchema = external_exports.object({
30043
30171
  * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
30044
30172
  * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
30045
30173
  seq: external_exports.number().int().optional(),
30174
+ /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
30175
+ * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
30176
+ * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
30177
+ * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
30178
+ * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
30179
+ * server that can still see every row states it. Absent on any row with no `askId`: a
30180
+ * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
30181
+ * the one part of its own arrival a single row cannot answer. */
30182
+ units: external_exports.number().int().positive().optional(),
30046
30183
  tokenId: external_exports.string().optional(),
30047
30184
  status: NotifyStatusSchema,
30048
30185
  context: ContextSchema,
@@ -30063,6 +30200,16 @@ var InboxItemSchema = external_exports.object({
30063
30200
  * while the party called the same dead claim stalled. Absent = no token/no data,
30064
30201
  * which must never CLAIM stalled. */
30065
30202
  lastSeenAt: external_exports.string().optional(),
30203
+ /** WHEN THE AGENT LAST SAID ANYTHING ABOUT THIS CLAIM — the newest `agent_state` row in
30204
+ * the `notification_events` ledger (trigger-written since 20260621010000, so every row a
30205
+ * user can see has one). The age input for `CLAIM_STALE_MS`, and it has to be this rather
30206
+ * than `createdAt`: a claim is very often picked up long after the row was born — the
30207
+ * inbox keeps an ANSWERED row visible while the agent works the follow-up, so a question
30208
+ * asked this morning and claimed a minute ago is eight hours old and one minute into its
30209
+ * work. Reading the row's birth as the claim's age brands that "No update in 8h" the
30210
+ * instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
30211
+ * `createdAt`. */
30212
+ agentStateAt: external_exports.string().datetime().optional(),
30066
30213
  agenda: external_exports.array(AgendaTurnSchema).optional(),
30067
30214
  /** On a replied detail (#397): the next steps the user attached to the answer
30068
30215
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -30134,11 +30281,23 @@ var SnoozeRequestSchema = external_exports.object({
30134
30281
  requestId: external_exports.string(),
30135
30282
  until: external_exports.string().datetime()
30136
30283
  });
30284
+ var APNS_TOKEN_RE = /^[0-9a-fA-F]{64}$/;
30137
30285
  var PushTokenSchema = external_exports.object({
30138
30286
  voipToken: external_exports.string().min(1).optional(),
30139
30287
  alertToken: external_exports.string().min(1).optional(),
30140
30288
  fcmToken: external_exports.string().min(1).optional(),
30141
30289
  platform: external_exports.enum(["ios", "android"])
30290
+ }).superRefine((v, ctx) => {
30291
+ if (v.platform !== "ios") return;
30292
+ for (const field of ["voipToken", "alertToken"]) {
30293
+ const token = v[field];
30294
+ if (token === void 0 || APNS_TOKEN_RE.test(token)) continue;
30295
+ ctx.addIssue({
30296
+ code: external_exports.ZodIssueCode.custom,
30297
+ path: [field],
30298
+ message: `not an APNs device token (want 64 hex chars, got ${token.length})`
30299
+ });
30300
+ }
30142
30301
  });
30143
30302
  var MissedCallSchema = external_exports.enum([
30144
30303
  "retry_10m",
@@ -30189,6 +30348,15 @@ var UserSettingsSchema = external_exports.object({
30189
30348
  /** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
30190
30349
  * defaulted — an older client PATCHing the full object must not clobber it. */
30191
30350
  pstnCalls: external_exports.boolean().optional(),
30351
+ /** The user's IANA timezone (e.g. "America/Bogota"), recorded by the app — it is the
30352
+ * only party that knows it. REMINDERS are why it exists: "remind me at ten" becomes
30353
+ * an absolute `due_at` only if we know whose ten. Optional and never defaulted, for
30354
+ * the same reason `voiceMode` is (a stale client PATCHing the whole object must not
30355
+ * clobber it) and one more: a GUESSED timezone schedules reminders hours off, and
30356
+ * that failure reads as the reminder rail being unreliable rather than as a missing
30357
+ * setting. Absent = a spoken time can't be landed, so the reminder rides the next
30358
+ * call — honest about what we know. */
30359
+ timezone: external_exports.string().min(1).max(64).optional(),
30192
30360
  /** Account E2EE state (text lane): 'off' (default) = today's plaintext; 'on' =
30193
30361
  * content is sealed end-to-end between the local agent and the phone. Like
30194
30362
  * voiceMode, OPTIONAL and NOT defaulted so a stale client PATCHing the full
@@ -30214,6 +30382,15 @@ var HistoryItemSchema = external_exports.object({
30214
30382
  /** When you answered the agent's notification (agent→user only). */
30215
30383
  humanAckedAt: external_exports.string().nullable()
30216
30384
  });
30385
+ var ACTIVITY_LINES = 2;
30386
+ var ACTIVITY_LINE_MAX = 80;
30387
+ var AgentActivitySchema = external_exports.object({
30388
+ /** Oldest first, so the newest line is last — the one that replaces in place. */
30389
+ lines: external_exports.array(external_exports.string().max(ACTIVITY_LINE_MAX)).max(ACTIVITY_LINES),
30390
+ /** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
30391
+ * that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
30392
+ at: external_exports.string().datetime()
30393
+ });
30217
30394
  var ConnectionSummarySchema = external_exports.object({
30218
30395
  /** The connection = the agent's token id (used to address a request). */
30219
30396
  id: external_exports.string(),
@@ -30247,6 +30424,11 @@ var ConnectionSummarySchema = external_exports.object({
30247
30424
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
30248
30425
  workspaces: external_exports.array(external_exports.string()).optional()
30249
30426
  }).optional(),
30427
+ /** The tail of this agent's working log, when a harness is driving it — the agent page's
30428
+ * live strip. Absent for anything the desktop harness isn't running (a hatched identity
30429
+ * used straight from a terminal emits no work events; the page says so rather than
30430
+ * drawing an empty box). */
30431
+ activity: AgentActivitySchema.optional(),
30250
30432
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
30251
30433
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
30252
30434
  managed: external_exports.boolean()
@@ -30315,7 +30497,8 @@ var HandoffSchema = external_exports.object({
30315
30497
  recap: external_exports.boolean().optional()
30316
30498
  });
30317
30499
  var NoteSourceSchema = external_exports.enum(["app", "call"]);
30318
- var NoteStatusSchema = external_exports.enum(["open", "assigned", "done"]);
30500
+ var NoteStatusSchema = external_exports.enum(["open", "assigned", "in_progress", "done"]);
30501
+ var NoteRepeatSchema = external_exports.enum(["once", "until_done"]);
30319
30502
  var DecisionSchema = external_exports.object({
30320
30503
  id: external_exports.string(),
30321
30504
  /** The note this decision refines; null = recorded on a bare thread (the
@@ -30340,11 +30523,29 @@ var NoteSchema = external_exports.object({
30340
30523
  assignee: external_exports.string().nullable(),
30341
30524
  /** The request thread minted at assignment; null until assigned. */
30342
30525
  parentId: external_exports.string().nullable(),
30526
+ /** REMINDERS (reminders-design.md): the NOT-BEFORE this becomes eligible to ride a
30527
+ * call — never a deadline, and nothing rings when it passes. Null = "the very next
30528
+ * call", the right reading of "remind me to…" with no time attached. */
30529
+ // Defaulted, not required: a Note from an API deploy older than the reminders
30530
+ // migration has none of these, and the defaults ARE what it means — no not-before,
30531
+ // one ride, never ridden. Parsing must not fail across a rolling deploy.
30532
+ dueAt: external_exports.string().nullable().default(null),
30533
+ repeat: NoteRepeatSchema.default("once"),
30534
+ /** How many calls have already carried it — the fatigue cap counts rides, not days. */
30535
+ rides: external_exports.number().int().default(0),
30536
+ lastRideAt: external_exports.string().nullable().default(null),
30343
30537
  createdAt: external_exports.string()
30344
30538
  });
30345
30539
  var CreateNoteSchema = external_exports.object({
30346
30540
  /** The intent, in the user's own words. Stored verbatim; the broker only titles it. */
30347
- text: external_exports.string().min(1).max(4e3)
30541
+ text: external_exports.string().min(1).max(4e3),
30542
+ /** Capture it as a REMINDER — a note assigned to the user themselves, which rides
30543
+ * their next call instead of being handed to an agent. Everything else about the
30544
+ * note is identical; this is the one parameter that separates the two. */
30545
+ forMe: external_exports.boolean().optional(),
30546
+ /** The not-before, when the user already said one. Absent = the very next call. */
30547
+ dueAt: external_exports.string().datetime().optional(),
30548
+ repeat: NoteRepeatSchema.optional()
30348
30549
  });
30349
30550
  var RecordDecisionSchema = external_exports.object({
30350
30551
  /** An open decision (from /clarify) to answer. */
@@ -31081,10 +31282,14 @@ async function claimSessions(opts = {}) {
31081
31282
  return (await res.json()).sessions;
31082
31283
  }
31083
31284
  async function heartbeat(runtime, opts = {}) {
31285
+ const body = {
31286
+ ...runtime !== void 0 ? { runtime } : {},
31287
+ ...opts.activity !== void 0 ? { activity: opts.activity } : {}
31288
+ };
31084
31289
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/presence`, {
31085
31290
  method: "POST",
31086
31291
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
31087
- ...runtime !== void 0 ? { body: JSON.stringify({ runtime }) } : {}
31292
+ ...Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}
31088
31293
  }));
31089
31294
  if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
31090
31295
  }
@@ -31158,6 +31363,12 @@ var ReceiptEventSchema2 = external_exports.enum([
31158
31363
  // the recipient opened it
31159
31364
  "answered",
31160
31365
  // the recipient replied
31366
+ // The recipient TURNED THE RING DOWN — CallKit ended it and no answer was ever tapped.
31367
+ // Written by the phone, on the same door that reports the ring itself, so it exists only
31368
+ // when a ring reached a running app and a person did not take it. That is what separates
31369
+ // it from "a ring with no answer", which our own crashes wrote just as readily and which
31370
+ // is why the responsiveness back-off had to be removed (#1144).
31371
+ "declined",
31161
31372
  "escalated",
31162
31373
  // re-reached at a higher level (re-ring / promote)
31163
31374
  "coalesced",
@@ -31166,8 +31377,14 @@ var ReceiptEventSchema2 = external_exports.enum([
31166
31377
  // deadline passed unanswered
31167
31378
  "woke",
31168
31379
  // the agent was woken for an owed obligation (callback)
31169
- "gave_up"
31380
+ "gave_up",
31170
31381
  // the budget was spent — stopped re-engaging
31382
+ // The ladder starts over — a silent pickup (the owner's fresh-miss rule, 2026-07-28), a
31383
+ // promote to call, a re-delivery. APPENDED, never a rewind: `ring_step` was a cache of
31384
+ // the escalated-count and a writer rewound it to 0 on every unanswered call (2026-08-24,
31385
+ // nine rings in an hour, `gaveUp` unreachable). A count since the last `restarted` cannot
31386
+ // be rewound by a writer that forgot to advance it.
31387
+ "restarted"
31171
31388
  ]);
31172
31389
  var AttentionSchema2 = external_exports.object({
31173
31390
  urgency: NotifyLevelSchema2,
@@ -31299,7 +31516,7 @@ var NotifyRequestSchema2 = external_exports.object({
31299
31516
  if (r.ask !== void 0) {
31300
31517
  for (const f of ["context", "select", "points"]) {
31301
31518
  if (r[f] !== void 0)
31302
- 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)` });
31519
+ 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.` });
31303
31520
  }
31304
31521
  return;
31305
31522
  }
@@ -31374,6 +31591,14 @@ var IntentSchema2 = external_exports.object({
31374
31591
  * about what cannot answer "is the bot looping less this week?". */
31375
31592
  fault: external_exports.enum(["loop", "unanswered", "overridden", "misheard", "slow", "other"]).optional()
31376
31593
  });
31594
+ var RideAlongSchema2 = external_exports.object({
31595
+ /** The note this came from — assign/clarify/close it through /api/notes/:id. */
31596
+ noteId: external_exports.string(),
31597
+ /** What to do, in the owner's own words (the note's headline). Never model-rewritten. */
31598
+ text: external_exports.string(),
31599
+ /** The thread to report back on, when the note was dispatched over the request rail. */
31600
+ parentId: external_exports.string().nullable()
31601
+ });
31377
31602
  var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
31378
31603
  external_exports.object({
31379
31604
  type: external_exports.literal("reply"),
@@ -31394,7 +31619,13 @@ var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
31394
31619
  transcript: external_exports.string().optional(),
31395
31620
  /** Coverage report (#396), when the ask declared `points`: which of them this
31396
31621
  * answer addressed. Missing points = re-ask or proceed knowingly partial. */
31397
- covered: external_exports.array(external_exports.string()).optional()
31622
+ covered: external_exports.array(external_exports.string()).optional(),
31623
+ /** Ride-alongs (RideAlongSchema) — pending work for you, attached to the moment you
31624
+ * became free. Only `reply` and `idle` carry it: those are the two outcomes that
31625
+ * END a wait. `remind`, `superseded` and `turn` are mid-flight, and handing an
31626
+ * agent a side-quest while it is still holding the line is how the main thing gets
31627
+ * dropped. Absent/empty = nothing owed. */
31628
+ also: external_exports.array(RideAlongSchema2).optional()
31398
31629
  }),
31399
31630
  external_exports.object({
31400
31631
  type: external_exports.literal("remind"),
@@ -31429,7 +31660,7 @@ var AwaitItemSchema2 = external_exports.discriminatedUnion("type", [
31429
31660
  acts: external_exports.array(IntentSchema2).nullable().optional()
31430
31661
  })
31431
31662
  }),
31432
- external_exports.object({ type: external_exports.literal("idle") })
31663
+ external_exports.object({ type: external_exports.literal("idle"), also: external_exports.array(RideAlongSchema2).optional() })
31433
31664
  ]);
31434
31665
  var CallbackTriggerSchema2 = external_exports.enum(["on_done", "on_blocked", "scheduled"]);
31435
31666
  var ScheduleCallbackSchema2 = external_exports.object({
@@ -31460,6 +31691,12 @@ var PendingRepliesSchema2 = external_exports.object({
31460
31691
  pending: external_exports.array(
31461
31692
  external_exports.object({ parentId: external_exports.string(), notificationId: external_exports.string(), createdAt: external_exports.string() })
31462
31693
  ),
31694
+ /** WHO YOU ARE on this account (field report 2026-08-28): the name and device the user
31695
+ * sees for this session's identity. From inside a session there was no way to find out —
31696
+ * `pair` with no arguments can HATCH a fresh identity, so it is not a safe probe — and an
31697
+ * agent that cannot tell which agent it is cannot tell whether work addressed to
31698
+ * "Reta" was addressed to it. Absent only for a token with no pairing behind it. */
31699
+ you: external_exports.object({ name: external_exports.string(), device: external_exports.string().nullable(), tokenId: external_exports.string() }).optional(),
31463
31700
  /** User-initiated requests addressed to this agent; act on them and reply via
31464
31701
  * contact on the same parentId. Keeps reappearing until you call
31465
31702
  * set_task_state on its notificationId. */
@@ -31471,12 +31708,21 @@ var PendingRepliesSchema2 = external_exports.object({
31471
31708
  createdAt: external_exports.string(),
31472
31709
  /** The user seeded this request with a past conversation — call get_thread on it
31473
31710
  * FIRST and treat the transcript as prior context (#57/#251). */
31474
- contextParentId: external_exports.string().optional()
31711
+ contextParentId: external_exports.string().optional(),
31712
+ /** STRANDED (field report 2026-08-28): this request was addressed to ANOTHER agent on
31713
+ * the account — the name here — which has not been seen since it landed, so nobody
31714
+ * came for it. Handed to you because you are the session that is here. Take it like
31715
+ * any request (set_task_state claims it, reply with contact on its parentId), and say
31716
+ * whose it was, because the user chose that agent on purpose. */
31717
+ stranded: external_exports.string().optional()
31475
31718
  })
31476
31719
  ),
31477
31720
  /** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
31478
31721
  * if blocked, or at a time that has passed). Re-surfaced every sweep until you
31479
31722
  * fulfill one by calling contact on its parentId. */
31723
+ /** Ride-alongs (RideAlongSchema): notes assigned to this agent that no wake could
31724
+ * reach. Same array the contact/await replies carry — one queue, every carrier. */
31725
+ also: external_exports.array(RideAlongSchema2).optional(),
31480
31726
  owedCallbacks: external_exports.array(
31481
31727
  external_exports.object({ parentId: external_exports.string(), trigger: CallbackTriggerSchema2, note: external_exports.string() })
31482
31728
  ),
@@ -31512,7 +31758,11 @@ var NotifyResponseSchema2 = external_exports.object({
31512
31758
  status: NotifyStatusSchema2,
31513
31759
  createdAt: external_exports.string().datetime(),
31514
31760
  answer: UserAnswerSchema2.optional(),
31515
- answeredAt: external_exports.string().datetime().optional()
31761
+ answeredAt: external_exports.string().datetime().optional(),
31762
+ /** Ride-alongs for THIS agent — pending work it should pick up when it's done with
31763
+ * what it came for. Present on any reply, because an unwakeable agent's only
31764
+ * reliable moment is one it initiated. Absent/empty = nothing owed. */
31765
+ also: external_exports.array(RideAlongSchema2).optional()
31516
31766
  });
31517
31767
  var NotifyPlanUnitSchema2 = external_exports.object({
31518
31768
  notificationId: external_exports.string(),
@@ -31534,7 +31784,13 @@ var NotifyPlanUnitSchema2 = external_exports.object({
31534
31784
  proposal: external_exports.object({
31535
31785
  select: SelectShapeSchema2,
31536
31786
  options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
31537
- }).optional()
31787
+ }).optional(),
31788
+ /** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
31789
+ * between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
31790
+ * needs to know. Reported so the agent can correct a misread the same way it ratifies a
31791
+ * shape — the read RAISES (a decision always asks) and never silences a question the
31792
+ * agent declared (#731, #923). */
31793
+ wants: external_exports.enum(["decision", "approval", "knowledge"]).optional()
31538
31794
  });
31539
31795
  var NotifyPlanSchema2 = external_exports.object({
31540
31796
  units: external_exports.array(NotifyPlanUnitSchema2),
@@ -31558,6 +31814,9 @@ var UserResponseSchema2 = external_exports.object({
31558
31814
  });
31559
31815
  var VoiceKeySchema2 = external_exports.enum(["rachel", "george", "jessica", "brian", "lily"]);
31560
31816
  var AgendaTurnSchema2 = external_exports.object({
31817
+ /** Twin coverage (#1089): sibling claim ids this asking turn's answer ALSO settles —
31818
+ * the planner declares duplicates instead of asking them twice. */
31819
+ coveredIds: external_exports.array(external_exports.string()).optional(),
31561
31820
  /** At most three short spoken sentences. Capped because a turn is a breath: a 1031-char
31562
31821
  * line went out on 2026-07-28 and the caller could not answer it at all. */
31563
31822
  info: external_exports.array(external_exports.string().min(1)).max(3).default([]),
@@ -31591,6 +31850,7 @@ var AgendaTurnSchema2 = external_exports.object({
31591
31850
  * on, the claim stays pending; blocking:true on context = hold for a reply. */
31592
31851
  blocking: external_exports.boolean().optional()
31593
31852
  });
31853
+ var CLAIM_STALE_MS2 = 30 * 6e4;
31594
31854
  var InboxItemSchema2 = external_exports.object({
31595
31855
  id: external_exports.string(),
31596
31856
  /** The conversation thread + connection this item lives on. Present on the replied
@@ -31610,6 +31870,15 @@ var InboxItemSchema2 = external_exports.object({
31610
31870
  * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
31611
31871
  * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
31612
31872
  seq: external_exports.number().int().optional(),
31873
+ /** HOW MANY units the arrival was cut into. A device reads a LENS, never the arrival —
31874
+ * `/api/inbox` serves `open`, so the units already settled are gone from it — and a client
31875
+ * counting what it can see is counting what is LEFT. Walking a three-unit ask on the answer
31876
+ * screen read "1 of 3", then "1 of 2", then no chip at all, each answer having removed the
31877
+ * only evidence of itself. How big an arrival is, is a fact about the arrival, so the
31878
+ * server that can still see every row states it. Absent on any row with no `askId`: a
31879
+ * unit knows WHICH ask it came from and WHERE it sat in it, and how many there were is
31880
+ * the one part of its own arrival a single row cannot answer. */
31881
+ units: external_exports.number().int().positive().optional(),
31613
31882
  tokenId: external_exports.string().optional(),
31614
31883
  status: NotifyStatusSchema2,
31615
31884
  context: ContextSchema2,
@@ -31630,6 +31899,16 @@ var InboxItemSchema2 = external_exports.object({
31630
31899
  * while the party called the same dead claim stalled. Absent = no token/no data,
31631
31900
  * which must never CLAIM stalled. */
31632
31901
  lastSeenAt: external_exports.string().optional(),
31902
+ /** WHEN THE AGENT LAST SAID ANYTHING ABOUT THIS CLAIM — the newest `agent_state` row in
31903
+ * the `notification_events` ledger (trigger-written since 20260621010000, so every row a
31904
+ * user can see has one). The age input for `CLAIM_STALE_MS`, and it has to be this rather
31905
+ * than `createdAt`: a claim is very often picked up long after the row was born — the
31906
+ * inbox keeps an ANSWERED row visible while the agent works the follow-up, so a question
31907
+ * asked this morning and claimed a minute ago is eight hours old and one minute into its
31908
+ * work. Reading the row's birth as the claim's age brands that "No update in 8h" the
31909
+ * instant the agent picks it up (#997). Absent = pre-trigger row; fall back to
31910
+ * `createdAt`. */
31911
+ agentStateAt: external_exports.string().datetime().optional(),
31633
31912
  agenda: external_exports.array(AgendaTurnSchema2).optional(),
31634
31913
  /** On a replied detail (#397): the next steps the user attached to the answer
31635
31914
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -31701,11 +31980,23 @@ var SnoozeRequestSchema2 = external_exports.object({
31701
31980
  requestId: external_exports.string(),
31702
31981
  until: external_exports.string().datetime()
31703
31982
  });
31983
+ var APNS_TOKEN_RE2 = /^[0-9a-fA-F]{64}$/;
31704
31984
  var PushTokenSchema2 = external_exports.object({
31705
31985
  voipToken: external_exports.string().min(1).optional(),
31706
31986
  alertToken: external_exports.string().min(1).optional(),
31707
31987
  fcmToken: external_exports.string().min(1).optional(),
31708
31988
  platform: external_exports.enum(["ios", "android"])
31989
+ }).superRefine((v, ctx) => {
31990
+ if (v.platform !== "ios") return;
31991
+ for (const field of ["voipToken", "alertToken"]) {
31992
+ const token = v[field];
31993
+ if (token === void 0 || APNS_TOKEN_RE2.test(token)) continue;
31994
+ ctx.addIssue({
31995
+ code: external_exports.ZodIssueCode.custom,
31996
+ path: [field],
31997
+ message: `not an APNs device token (want 64 hex chars, got ${token.length})`
31998
+ });
31999
+ }
31709
32000
  });
31710
32001
  var MissedCallSchema2 = external_exports.enum([
31711
32002
  "retry_10m",
@@ -31756,6 +32047,15 @@ var UserSettingsSchema2 = external_exports.object({
31756
32047
  /** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
31757
32048
  * defaulted — an older client PATCHing the full object must not clobber it. */
31758
32049
  pstnCalls: external_exports.boolean().optional(),
32050
+ /** The user's IANA timezone (e.g. "America/Bogota"), recorded by the app — it is the
32051
+ * only party that knows it. REMINDERS are why it exists: "remind me at ten" becomes
32052
+ * an absolute `due_at` only if we know whose ten. Optional and never defaulted, for
32053
+ * the same reason `voiceMode` is (a stale client PATCHing the whole object must not
32054
+ * clobber it) and one more: a GUESSED timezone schedules reminders hours off, and
32055
+ * that failure reads as the reminder rail being unreliable rather than as a missing
32056
+ * setting. Absent = a spoken time can't be landed, so the reminder rides the next
32057
+ * call — honest about what we know. */
32058
+ timezone: external_exports.string().min(1).max(64).optional(),
31759
32059
  /** Account E2EE state (text lane): 'off' (default) = today's plaintext; 'on' =
31760
32060
  * content is sealed end-to-end between the local agent and the phone. Like
31761
32061
  * voiceMode, OPTIONAL and NOT defaulted so a stale client PATCHing the full
@@ -31781,6 +32081,15 @@ var HistoryItemSchema2 = external_exports.object({
31781
32081
  /** When you answered the agent's notification (agent→user only). */
31782
32082
  humanAckedAt: external_exports.string().nullable()
31783
32083
  });
32084
+ var ACTIVITY_LINES2 = 2;
32085
+ var ACTIVITY_LINE_MAX2 = 80;
32086
+ var AgentActivitySchema2 = external_exports.object({
32087
+ /** Oldest first, so the newest line is last — the one that replaces in place. */
32088
+ lines: external_exports.array(external_exports.string().max(ACTIVITY_LINE_MAX2)).max(ACTIVITY_LINES2),
32089
+ /** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
32090
+ * that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
32091
+ at: external_exports.string().datetime()
32092
+ });
31784
32093
  var ConnectionSummarySchema2 = external_exports.object({
31785
32094
  /** The connection = the agent's token id (used to address a request). */
31786
32095
  id: external_exports.string(),
@@ -31814,6 +32123,11 @@ var ConnectionSummarySchema2 = external_exports.object({
31814
32123
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
31815
32124
  workspaces: external_exports.array(external_exports.string()).optional()
31816
32125
  }).optional(),
32126
+ /** The tail of this agent's working log, when a harness is driving it — the agent page's
32127
+ * live strip. Absent for anything the desktop harness isn't running (a hatched identity
32128
+ * used straight from a terminal emits no work events; the page says so rather than
32129
+ * drawing an empty box). */
32130
+ activity: AgentActivitySchema2.optional(),
31817
32131
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
31818
32132
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
31819
32133
  managed: external_exports.boolean()
@@ -31882,7 +32196,8 @@ var HandoffSchema2 = external_exports.object({
31882
32196
  recap: external_exports.boolean().optional()
31883
32197
  });
31884
32198
  var NoteSourceSchema2 = external_exports.enum(["app", "call"]);
31885
- var NoteStatusSchema2 = external_exports.enum(["open", "assigned", "done"]);
32199
+ var NoteStatusSchema2 = external_exports.enum(["open", "assigned", "in_progress", "done"]);
32200
+ var NoteRepeatSchema2 = external_exports.enum(["once", "until_done"]);
31886
32201
  var DecisionSchema2 = external_exports.object({
31887
32202
  id: external_exports.string(),
31888
32203
  /** The note this decision refines; null = recorded on a bare thread (the
@@ -31907,11 +32222,29 @@ var NoteSchema2 = external_exports.object({
31907
32222
  assignee: external_exports.string().nullable(),
31908
32223
  /** The request thread minted at assignment; null until assigned. */
31909
32224
  parentId: external_exports.string().nullable(),
32225
+ /** REMINDERS (reminders-design.md): the NOT-BEFORE this becomes eligible to ride a
32226
+ * call — never a deadline, and nothing rings when it passes. Null = "the very next
32227
+ * call", the right reading of "remind me to…" with no time attached. */
32228
+ // Defaulted, not required: a Note from an API deploy older than the reminders
32229
+ // migration has none of these, and the defaults ARE what it means — no not-before,
32230
+ // one ride, never ridden. Parsing must not fail across a rolling deploy.
32231
+ dueAt: external_exports.string().nullable().default(null),
32232
+ repeat: NoteRepeatSchema2.default("once"),
32233
+ /** How many calls have already carried it — the fatigue cap counts rides, not days. */
32234
+ rides: external_exports.number().int().default(0),
32235
+ lastRideAt: external_exports.string().nullable().default(null),
31910
32236
  createdAt: external_exports.string()
31911
32237
  });
31912
32238
  var CreateNoteSchema2 = external_exports.object({
31913
32239
  /** The intent, in the user's own words. Stored verbatim; the broker only titles it. */
31914
- text: external_exports.string().min(1).max(4e3)
32240
+ text: external_exports.string().min(1).max(4e3),
32241
+ /** Capture it as a REMINDER — a note assigned to the user themselves, which rides
32242
+ * their next call instead of being handed to an agent. Everything else about the
32243
+ * note is identical; this is the one parameter that separates the two. */
32244
+ forMe: external_exports.boolean().optional(),
32245
+ /** The not-before, when the user already said one. Absent = the very next call. */
32246
+ dueAt: external_exports.string().datetime().optional(),
32247
+ repeat: NoteRepeatSchema2.optional()
31915
32248
  });
31916
32249
  var RecordDecisionSchema2 = external_exports.object({
31917
32250
  /** An open decision (from /clarify) to answer. */
@@ -32690,9 +33023,30 @@ function startSession(opts) {
32690
33023
  };
32691
33024
  }
32692
33025
 
33026
+ // src/paigy/activity.ts
33027
+ function shortenPaths(s) {
33028
+ return s.replace(/(?<![\w.@+-])(?:\/[\w.@+-]+){3,}/g, (p) => {
33029
+ const parts = p.split("/").filter(Boolean);
33030
+ return `\u2026/${parts.slice(-2).join("/")}`;
33031
+ });
33032
+ }
33033
+ function workLine(event) {
33034
+ const note = (event.note ?? "").split("\n").find((l) => l.trim()) ?? "";
33035
+ const line = shortenPaths([event.tool.trim(), note.trim()].filter(Boolean).join(" \u2014 ").replace(/\s+/g, " "));
33036
+ return line.length > ACTIVITY_LINE_MAX2 ? `${line.slice(0, ACTIVITY_LINE_MAX2 - 1)}\u2026` : line;
33037
+ }
33038
+ function pushWork(lines, line) {
33039
+ if (!line || lines[lines.length - 1] === line) return [...lines];
33040
+ return [...lines, line].slice(-ACTIVITY_LINES2);
33041
+ }
33042
+ function sameTail(a, b) {
33043
+ return a.length === b.length && a.every((l, i) => l === b[i]);
33044
+ }
33045
+
32693
33046
  // src/run.ts
32694
33047
  function runHarness(opts) {
32695
33048
  let running = true;
33049
+ let tail = [];
32696
33050
  const state = { ...opts.exclusive ? { exclusive: true } : {} };
32697
33051
  let session = null;
32698
33052
  const asMe = { token: opts.token };
@@ -32722,6 +33076,7 @@ function runHarness(opts) {
32722
33076
  }
32723
33077
  if (event.kind === "work") {
32724
33078
  opts.log(`\u2699 ${event.tool}${event.note ? ` \u2014 ${event.note.split("\n")[0] ?? ""}` : ""}`);
33079
+ tail = pushWork(tail, workLine(event));
32725
33080
  return;
32726
33081
  }
32727
33082
  const { parentId } = await mirror(event, state, deps);
@@ -32729,6 +33084,7 @@ function runHarness(opts) {
32729
33084
  if (event.kind === "turn") opts.log(`${event.role}: ${event.text.split("\n")[0] ?? ""}`);
32730
33085
  if (event.kind === "idle") {
32731
33086
  state.resting = true;
33087
+ tail = [];
32732
33088
  if (endsWithQuestion(event.result)) {
32733
33089
  const local = await opts.localAsk?.question?.(event.result ?? "") ?? null;
32734
33090
  if (local?.trim() && session && running) {
@@ -32766,8 +33122,10 @@ function runHarness(opts) {
32766
33122
  session?.send(text);
32767
33123
  },
32768
33124
  working: () => running && state.resting !== true,
33125
+ tail: () => [...tail],
32769
33126
  stop() {
32770
33127
  running = false;
33128
+ tail = [];
32771
33129
  cancelAsks(state);
32772
33130
  session?.stop();
32773
33131
  session = null;
@@ -32820,6 +33178,12 @@ function resolveWakeDir(pinned, deps) {
32820
33178
 
32821
33179
  // src/host.ts
32822
33180
  var HOST_FILE = join4(homedir5(), ".paigy", "host.json");
33181
+ var IDLE_WAKE_MS = 10 * 6e4;
33182
+ function wakeAction(live, seenAt, now) {
33183
+ if (!live) return "spawn";
33184
+ if (seenAt === void 0) return "skip";
33185
+ return now - seenAt >= IDLE_WAKE_MS ? "prompt" : "skip";
33186
+ }
32823
33187
  function startHost(opts) {
32824
33188
  const runs = /* @__PURE__ */ new Map();
32825
33189
  const asHost = { token: opts.token };
@@ -32875,12 +33239,15 @@ function startHost(opts) {
32875
33239
  }
32876
33240
  }
32877
33241
  const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex", antigravity: "agy" };
32878
- 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.";
33242
+ 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.";
32879
33243
  async function sweepSlots() {
32880
33244
  for (const slot of listSlots()) {
32881
33245
  const harness = SLOT_HARNESS[slot] ?? (slot === "Desktop" ? void 0 : "claude");
32882
33246
  const key = `slot:${slot}`;
32883
- if (!harness || runs.has(key)) continue;
33247
+ if (!harness) continue;
33248
+ const live = runs.get(key);
33249
+ const action = wakeAction(!!live, published.get(key)?.movedAt, Date.now());
33250
+ if (action === "skip") continue;
32884
33251
  const token = readToken(slot);
32885
33252
  if (!token) continue;
32886
33253
  const workspace = resolveWakeDir(slotIdentity(slot).workspace, opts.wsDeps);
@@ -32888,12 +33255,20 @@ function startHost(opts) {
32888
33255
  const work = await checkReplies({ token }).catch(() => null);
32889
33256
  if (!work || work.requests.length === 0 && work.replies.length === 0) continue;
32890
33257
  const label = slotName(slot) ?? slot;
33258
+ if (live) {
33259
+ const seen = published.get(key);
33260
+ const quiet = seen ? Math.round((Date.now() - seen.movedAt) / 6e4) : 0;
33261
+ live.run.send(wakePrompt(false));
33262
+ if (seen) seen.movedAt = Date.now();
33263
+ opts.log(`\u25B6 re-woke ${label} \u2014 quiet ${quiet}m with work waiting`);
33264
+ continue;
33265
+ }
32891
33266
  const log = (line) => opts.log(`[${label}] ${line}`);
32892
33267
  const run = runHarness({
32893
33268
  harness,
32894
33269
  cwd: workspace,
32895
33270
  mode: "bypass",
32896
- prompt: WAKE_PROMPT,
33271
+ prompt: wakePrompt(true),
32897
33272
  exclusive: true,
32898
33273
  token,
32899
33274
  // A dead run must not squat the slot — evict so the next wake can respawn.
@@ -32909,6 +33284,27 @@ function startHost(opts) {
32909
33284
  opts.log(`\u25B6 woke ${label} (${harness}) \u2014 work was waiting in ${workspace}`);
32910
33285
  }
32911
33286
  }
33287
+ const ACTIVITY_MS = 2e3;
33288
+ const published = /* @__PURE__ */ new Map();
33289
+ const streamActivity = () => {
33290
+ const publishTail = (token, lines) => {
33291
+ void heartbeat(void 0, { token, activity: { lines, at: (/* @__PURE__ */ new Date()).toISOString() } }).catch(() => {
33292
+ });
33293
+ };
33294
+ for (const [key, r] of runs) {
33295
+ if (!r.token) continue;
33296
+ const lines = r.run.tail();
33297
+ const was = published.get(key);
33298
+ if (was && sameTail(was.lines, lines)) continue;
33299
+ published.set(key, { token: r.token, lines, movedAt: Date.now() });
33300
+ publishTail(r.token, lines);
33301
+ }
33302
+ for (const [key, was] of published) {
33303
+ if (runs.has(key)) continue;
33304
+ published.delete(key);
33305
+ if (was.lines.length > 0) publishTail(was.token, []);
33306
+ }
33307
+ };
32912
33308
  const publish = () => {
32913
33309
  try {
32914
33310
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: Date.now(), roster: api.roster() }));
@@ -32922,6 +33318,7 @@ function startHost(opts) {
32922
33318
  void sweepSlots();
32923
33319
  publish();
32924
33320
  }, 5e3);
33321
+ const activityTick = setInterval(streamActivity, ACTIVITY_MS);
32925
33322
  const stopSessions = () => {
32926
33323
  for (const { run, label } of runs.values()) {
32927
33324
  run.stop();
@@ -32959,6 +33356,8 @@ function startHost(opts) {
32959
33356
  clearInterval(pulse);
32960
33357
  clearInterval(spawnPoll);
32961
33358
  stopSessions();
33359
+ streamActivity();
33360
+ clearInterval(activityTick);
32962
33361
  try {
32963
33362
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: 0, roster: [] }));
32964
33363
  } catch {
@@ -32970,74 +33369,38 @@ function startHost(opts) {
32970
33369
  }
32971
33370
 
32972
33371
  // src/cli.ts
32973
- function parseArgs(argv) {
32974
- const args = { harness: "claude", mode: "bypass", cwd: process.cwd(), doctor: false, host: false, pair: false, service: false, setup: false, grant: [], hatch: null, voice: null, slot: null, identity: null, graceSeconds: 90, prompt: "", handoff: false };
32975
- const words = [];
32976
- for (let i = 0; i < argv.length; i++) {
32977
- const arg = argv[i];
32978
- const next = () => argv[++i];
32979
- if (arg === "--harness") {
32980
- const v = next();
32981
- if (v !== "claude" && v !== "codex" && v !== "agy") return { error: `--harness must be claude, codex, or agy, got ${v ?? "nothing"}` };
32982
- args.harness = v;
32983
- } else if (arg === "--mode") {
32984
- const v = next();
32985
- if (v !== "bypass" && v !== "ask") return { error: `--mode must be bypass or ask, got ${v ?? "nothing"}` };
32986
- args.mode = v;
32987
- } else if (arg === "--cwd") {
32988
- const v = next();
32989
- if (!v) return { error: "--cwd needs a directory" };
32990
- args.cwd = v;
32991
- } else if (arg === "--doctor") {
32992
- args.doctor = true;
32993
- } else if (arg === "--grace") {
32994
- const v = Number(next());
32995
- if (!Number.isFinite(v) || v < 0) return { error: "--grace needs seconds (0 = straight to phone)" };
32996
- args.graceSeconds = v;
32997
- } else if (arg === "host") {
32998
- args.host = true;
32999
- } else if (arg === "pair") {
33000
- args.pair = true;
33001
- } else if (arg === "setup") {
33002
- args.setup = true;
33003
- } else if (arg === "service") {
33004
- args.service = true;
33005
- } else if (arg === "--grant") {
33006
- const v = next();
33007
- if (!v) return { error: "--grant needs a folder to allow" };
33008
- args.grant.push(v);
33009
- } else if (arg === "handoff") {
33010
- args.handoff = true;
33011
- } else if (arg === "hatch") {
33012
- const v = next();
33013
- if (!v) return { error: 'hatch needs a name: paigy-harness hatch "Voice bug hunter"' };
33014
- args.hatch = v;
33015
- } else if (arg === "--voice") {
33016
- args.voice = next() ?? null;
33017
- } else if (arg === "--slot") {
33018
- args.slot = next() ?? null;
33019
- } else if (arg === "--identity") {
33020
- const v = next();
33021
- if (!v) return { error: "--identity needs a hatched agent's name" };
33022
- args.identity = v;
33023
- } else if (arg?.startsWith("--")) {
33024
- return { error: `unknown flag ${arg}` };
33025
- } else if (arg) {
33026
- words.push(arg);
33027
- }
33028
- }
33029
- args.prompt = words.join(" ");
33030
- if (!args.doctor && !args.handoff && !args.hatch && !args.host && !args.pair && !args.service && !args.setup && !args.prompt) return { error: "a prompt is required (or setup / --doctor / pair / host / service / hatch NAME / handoff)" };
33031
- return args;
33032
- }
33033
33372
  var STATUS_MARK = { ready: "\u2713", login: "\u25D0", "adapter-missing": "\u25D0", missing: "\u2717" };
33034
33373
  async function main() {
33035
33374
  const parsed = parseArgs(process.argv.slice(2));
33036
33375
  if ("error" in parsed) {
33037
33376
  console.error(`paigy-harness: ${parsed.error}`);
33038
- console.error("usage: paigy-harness setup | pair | host [--grant DIR]\u2026 | service | hatch NAME [--voice KEY] [--slot SLOT] | handoff | [--harness claude|codex|agy] [--mode bypass|ask] [--cwd DIR] [--identity NAME] [--grace SECONDS] [--doctor] PROMPT\u2026");
33377
+ console.error(USAGE);
33039
33378
  process.exit(2);
33040
33379
  }
33380
+ if (parsed.help) {
33381
+ console.log(USAGE);
33382
+ console.log("");
33383
+ console.log(" setup pair this machine, set up every agent found here, install the host service");
33384
+ console.log(" pair pair this machine only (QR / code)");
33385
+ console.log(" host host sessions your phone launches (--grant DIR to allow a folder)");
33386
+ console.log(" service install the host as a login service (macOS)");
33387
+ console.log(" enable-tools allowlist Paigy's tools in Claude Code so they don't prompt each time");
33388
+ console.log(" hatch NAME mint a sibling identity from this machine's credential");
33389
+ console.log(" handoff pin this terminal's identity here so your phone can resume it");
33390
+ console.log(" --doctor report which agent CLIs are installed and ready");
33391
+ console.log("");
33392
+ console.log('Anything else is a PROMPT: paigy-harness --harness codex "fix the tests"');
33393
+ return;
33394
+ }
33395
+ if (parsed.enableTools) {
33396
+ try {
33397
+ execSync(`${ENABLE_TOOLS_COMMAND} --scope ${parsed.scope}`, { stdio: "inherit", shell: "/bin/sh" });
33398
+ } catch {
33399
+ console.error(`Couldn't run it \u2014 try directly: ${ENABLE_TOOLS_COMMAND} --scope ${parsed.scope}`);
33400
+ process.exit(1);
33401
+ }
33402
+ return;
33403
+ }
33041
33404
  if (parsed.doctor) {
33042
33405
  for (const a of detectAll()) {
33043
33406
  console.log(`${STATUS_MARK[a.status]} ${a.label}${a.hint ? ` \u2014 ${a.hint}` : ""}${a.install ? `
@@ -33120,6 +33483,14 @@ async function main() {
33120
33483
  } catch {
33121
33484
  }
33122
33485
  }
33486
+ if (which("claude")) {
33487
+ try {
33488
+ execSync(ENABLE_TOOLS_COMMAND, { stdio: "ignore", shell: "/bin/sh" });
33489
+ console.log("\u2713 Paigy's tools allowlisted in Claude Code (they won't prompt each time)");
33490
+ } catch {
33491
+ console.log(` allowlist it later with: paigy-harness enable-tools`);
33492
+ }
33493
+ }
33123
33494
  try {
33124
33495
  const settings = join5(homedir6(), ".claude", "settings.json");
33125
33496
  if (which("claude") && existsSync5(settings)) {
@@ -33306,9 +33677,6 @@ async function main() {
33306
33677
  });
33307
33678
  }
33308
33679
  void main();
33309
- export {
33310
- parseArgs
33311
- };
33312
33680
  /*! Bundled license information:
33313
33681
 
33314
33682
  undici/lib/web/fetch/body.js: