@paigy/harness 0.3.0 → 0.3.1

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 +204 -69
  2. package/dist/main.js +589 -496
  3. package/package.json +13 -12
package/dist/main.js CHANGED
@@ -11100,7 +11100,7 @@ var NotifyRequestSchema = external_exports.object({
11100
11100
  if (r.ask !== void 0) {
11101
11101
  for (const f of ["context", "select", "points"]) {
11102
11102
  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)` });
11103
+ 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
11104
  }
11105
11105
  return;
11106
11106
  }
@@ -11373,7 +11373,13 @@ var NotifyPlanUnitSchema = external_exports.object({
11373
11373
  proposal: external_exports.object({
11374
11374
  select: SelectShapeSchema,
11375
11375
  options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
11376
- }).optional()
11376
+ }).optional(),
11377
+ /** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
11378
+ * between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
11379
+ * needs to know. Reported so the agent can correct a misread the same way it ratifies a
11380
+ * shape — the read RAISES (a decision always asks) and never silences a question the
11381
+ * agent declared (#731, #923). */
11382
+ wants: external_exports.enum(["decision", "approval", "knowledge"]).optional()
11377
11383
  });
11378
11384
  var NotifyPlanSchema = external_exports.object({
11379
11385
  units: external_exports.array(NotifyPlanUnitSchema),
@@ -11620,6 +11626,15 @@ var HistoryItemSchema = external_exports.object({
11620
11626
  /** When you answered the agent's notification (agent→user only). */
11621
11627
  humanAckedAt: external_exports.string().nullable()
11622
11628
  });
11629
+ var ACTIVITY_LINES = 2;
11630
+ var ACTIVITY_LINE_MAX = 80;
11631
+ var AgentActivitySchema = external_exports.object({
11632
+ /** Oldest first, so the newest line is last — the one that replaces in place. */
11633
+ lines: external_exports.array(external_exports.string().max(ACTIVITY_LINE_MAX)).max(ACTIVITY_LINES),
11634
+ /** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
11635
+ * that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
11636
+ at: external_exports.string().datetime()
11637
+ });
11623
11638
  var ConnectionSummarySchema = external_exports.object({
11624
11639
  /** The connection = the agent's token id (used to address a request). */
11625
11640
  id: external_exports.string(),
@@ -11653,6 +11668,11 @@ var ConnectionSummarySchema = external_exports.object({
11653
11668
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
11654
11669
  workspaces: external_exports.array(external_exports.string()).optional()
11655
11670
  }).optional(),
11671
+ /** The tail of this agent's working log, when a harness is driving it — the agent page's
11672
+ * live strip. Absent for anything the desktop harness isn't running (a hatched identity
11673
+ * used straight from a terminal emits no work events; the page says so rather than
11674
+ * drawing an empty box). */
11675
+ activity: AgentActivitySchema.optional(),
11656
11676
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
11657
11677
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
11658
11678
  managed: external_exports.boolean()
@@ -12475,10 +12495,14 @@ async function claimSessions(opts = {}) {
12475
12495
  return (await res.json()).sessions;
12476
12496
  }
12477
12497
  async function heartbeat(runtime, opts = {}) {
12498
+ const body = {
12499
+ ...runtime !== void 0 ? { runtime } : {},
12500
+ ...opts.activity !== void 0 ? { activity: opts.activity } : {}
12501
+ };
12478
12502
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/presence`, {
12479
12503
  method: "POST",
12480
12504
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
12481
- ...runtime !== void 0 ? { body: JSON.stringify({ runtime }) } : {}
12505
+ ...Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}
12482
12506
  }));
12483
12507
  if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
12484
12508
  }
@@ -12632,499 +12656,202 @@ function resolveWakeDir(pinned, deps) {
12632
12656
  return listWorkspaces(deps)[0];
12633
12657
  }
12634
12658
 
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;
12659
+ // ../../packages/schema/dist/index.js
12660
+ var ContextSchema2 = external_exports.object({
12661
+ title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
12662
+ description: external_exports.array(external_exports.string().min(1)).describe(
12663
+ "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."
12664
+ )
12665
+ });
12666
+ var ParticipantSchema2 = external_exports.object({
12667
+ kind: external_exports.enum(["human", "agent"]),
12668
+ id: external_exports.string()
12669
+ });
12670
+ var TransformSchema2 = external_exports.enum([
12671
+ "structure",
12672
+ // shape intent into an answer contract; pick channel/urgency — broker `ask`, `select` shapes, `points`
12673
+ "request_more",
12674
+ // clarify / follow-ups / uncovered points; escalate inbox→call — {kind:'clarify'}, escalate, blocking
12675
+ "redirect",
12676
+ // seed / hand off a thread to a new recipient — handoff, "new session from this"
12677
+ "break_down",
12678
+ // one bundle → many sub-asks — checklist fan-out, `points`
12679
+ "coalesce",
12680
+ // many bundles → one — morning triage (#347), threading-supersede, digest
12681
+ "organize",
12682
+ // group related bundles onto one thread — threading (`parentId`), parent/clarify links
12683
+ "summarize"
12684
+ // reduce volume, keep decision value — 30-turn cap, spoken briefing
12685
+ ]);
12686
+ var OptionSchema2 = external_exports.object({
12687
+ id: external_exports.string(),
12688
+ label: external_exports.string(),
12689
+ // .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
12690
+ // the constraints below are what an agent reads when deciding to use these.
12691
+ html: external_exports.string().max(16384).describe(
12692
+ "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."
12693
+ ).optional(),
12694
+ image: external_exports.string().url().describe(
12695
+ "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
12696
+ ).optional()
12697
+ });
12698
+ var VisualSchema2 = external_exports.object({
12699
+ url: external_exports.string().url(),
12700
+ label: external_exports.string().optional()
12701
+ });
12702
+ var NotifyLevelSchema2 = external_exports.enum(["inbox", "push", "banner", "call"]);
12703
+ var SelectShapeSchema2 = external_exports.enum(["one", "many", "rank", "confirm", "text"]);
12704
+ var ReceiptEventSchema2 = external_exports.enum([
12705
+ "delivered",
12706
+ // the bundle reached the recipient at some level
12707
+ "seen",
12708
+ // the recipient opened it
12709
+ "answered",
12710
+ // the recipient replied
12711
+ "escalated",
12712
+ // re-reached at a higher level (re-ring / promote)
12713
+ "coalesced",
12714
+ // merged into another live claim
12715
+ "expired",
12716
+ // deadline passed unanswered
12717
+ "woke",
12718
+ // the agent was woken for an owed obligation (callback)
12719
+ "gave_up"
12720
+ // the budget was spent — stopped re-engaging
12721
+ ]);
12722
+ var AttentionSchema2 = external_exports.object({
12723
+ urgency: NotifyLevelSchema2,
12724
+ /** The required answer shape, or null for a plain notify that asks nothing back. */
12725
+ select: SelectShapeSchema2.nullable(),
12726
+ /** Coverage contract (#396) — points the answer must address; null = none declared. */
12727
+ points: external_exports.array(external_exports.string()).nullable(),
12728
+ /** Whether the ask blocks the sender — what lets arbitration escalate it on silence. */
12729
+ blocking: external_exports.boolean(),
12730
+ /** Reserved (MODEL.md lists it): a response deadline. No row column yet — a later Phase 2
12731
+ * slice wires it; optional so today's rows/callers project cleanly. */
12732
+ deadline: external_exports.string().datetime().nullable().optional()
12733
+ });
12734
+ var NotifyRequestSchema2 = external_exports.object({
12735
+ /** Plaintext message content. Present on the plaintext path (today's shape);
12736
+ * ABSENT on the E2EE path, where the sealed `envelope` below carries it. The
12737
+ * superRefine at the bottom enforces exactly one of the two. */
12738
+ context: ContextSchema2.optional(),
12739
+ options: external_exports.array(OptionSchema2.omit({ id: true })).min(1).optional().describe(
12740
+ "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)."
12741
+ ),
12742
+ points: external_exports.array(external_exports.string().min(1)).optional().describe(
12743
+ "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."
12744
+ ),
12745
+ visuals: external_exports.array(VisualSchema2).optional().describe(
12746
+ "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."
12747
+ ),
12748
+ /** Git repo the agent is working in ("owner/name"). Local MCP fills this from the checkout — omit unless overriding. */
12749
+ repo: external_exports.string().optional(),
12750
+ /** Git branch the agent is on. Local MCP fills this from the checkout — omit unless overriding. */
12751
+ branch: external_exports.string().optional(),
12752
+ /** Continue an existing conversation — the id of any notification in it (its root
12753
+ * is the conversation's identity). Omitted = start a new conversation. Renamed
12754
+ * from `parentId` (2026-08-03): one linkage system, the parent; the API edge
12755
+ * still accepts the old name from older clients. */
12756
+ parentId: external_exports.string().uuid().optional(),
12757
+ urgency: NotifyLevelSchema2.default("inbox").describe(
12758
+ "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."
12759
+ ),
12760
+ /** The request this one CLARIFIES — spawning a clarification keeps that parent
12761
+ * visible and marks it needs_input. Renamed from the old `parentId` (2026-08-03)
12762
+ * when `parentId` became the conversation handle: `parentId` says WHERE, this
12763
+ * says HOW. */
12764
+ clarifies: external_exports.string().optional(),
12765
+ /** E2EE (text lane): when the pairing is E2EE, the sealed replacements for the
12766
+ * plaintext content fields, keyed by field name. FINALIZED wire shape (was
12767
+ * provisional in the storage PR): a per-field map `{ context?, options?,
12768
+ * visuals? }` of opaque Envelopes — one seal per present content field, so a
12769
+ * message with no options/visuals seals only `context`. It COEXISTS with the
12770
+ * plaintext fields by mutual exclusion: the superRefine below requires that
12771
+ * when `envelope` is present the plaintext `context`/`options`/`visuals` are
12772
+ * ABSENT (and vice-versa), so a row is either fully plaintext or fully sealed —
12773
+ * never a readable half. The server persists this OPAQUELY into
12774
+ * notifications.envelope and relays it blindly; it never decrypts. Absent =
12775
+ * today's plaintext path (context/options/visuals carry the cleartext).
12776
+ * z.lazy because EnvelopeSchema is declared further down (E2EE section). */
12777
+ envelope: external_exports.object({
12778
+ context: external_exports.lazy(() => EnvelopeSchema2).optional(),
12779
+ options: external_exports.lazy(() => EnvelopeSchema2).optional(),
12780
+ visuals: external_exports.lazy(() => EnvelopeSchema2).optional()
12781
+ }).optional(),
12782
+ select: SelectShapeSchema2.optional().describe(
12783
+ "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."
12784
+ ),
12785
+ /** The simplified form (#395): instead of shaping the notification yourself
12786
+ * (context/select/options/urgency), state what you need to learn and why it
12787
+ * matters now — the broker derives the optimal shape and channel. Mutually
12788
+ * exclusive with `context` (and never sent alongside `envelope`: E2EE pairings
12789
+ * derive agent-side before sealing, so the server only ever shapes plaintext). */
12790
+ // 10k, not a sentence budget. What the human hears is bounded by the BROKER — it splits
12791
+ // the ask into topics and gives each one at most three sentences and one question
12792
+ // (broker/agenda-design.md) — not by a wire cap the agent has to pre-summarize under.
12793
+ // Owner, 2026-07-28: "our actual limitation on how long something is to the user should
12794
+ // come from the broker splitting and summarizing." The cap that remains is a size guard.
12795
+ ask: external_exports.string().min(1).max(1e4).optional().describe(
12796
+ '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.'
12797
+ ),
12798
+ needs: external_exports.array(external_exports.string().min(1)).optional().describe(
12799
+ "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."
12800
+ ),
12801
+ urgencyHint: external_exports.enum(["whenever", "soon", "now"]).optional().describe(
12802
+ "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."
12803
+ ),
12804
+ /** #575: the ONE self-report that replaces urgencyHint + blocking — what happens
12805
+ * to the agent's work while it waits. Normalized server-side into those two
12806
+ * fields (normalizeWaiting) so everything downstream is untouched; explicit
12807
+ * urgencyHint/blocking win when both are sent. */
12808
+ waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
12809
+ "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."
12810
+ ),
12811
+ /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
12812
+ * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
12813
+ * holding by default would charge every quiet claim that minute before any agent could
12814
+ * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
12815
+ * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
12816
+ confirm: external_exports.boolean().optional().describe(
12817
+ "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'."
12818
+ ),
12819
+ /** #575: a RELAY of the user's explicitly stated preference, never the agent's
12820
+ * choice. Outranks waiting in both directions: 'call' rings even for a
12821
+ * waiting:'none' "call me when it's done"; 'message' never rings even for
12822
+ * waiting:'hard'. */
12823
+ channel: external_exports.enum(["call", "message"]).optional().describe(
12824
+ "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."
12825
+ ),
12826
+ confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
12827
+ "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
12828
+ ),
12829
+ blocking: external_exports.boolean().default(false).describe(
12830
+ "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."
12831
+ )
12832
+ }).superRefine((r, ctx) => {
12833
+ const sealed = !!r.envelope;
12834
+ if (sealed) {
12835
+ if (!r.envelope?.context)
12836
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["envelope", "context"], message: "sealed request must include envelope.context" });
12837
+ for (const f of ["context", "options", "visuals"]) {
12838
+ if (r[f] !== void 0)
12839
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: [f], message: `E2EE request must not carry plaintext ${f} \u2014 it's sealed in envelope.${f}` });
12840
+ }
12841
+ if (r.points !== void 0)
12842
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["points"], message: "E2EE request must not carry plaintext points" });
12843
+ if (r.ask !== void 0)
12844
+ 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" });
12845
+ if (r.needs !== void 0)
12846
+ ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["needs"], message: "E2EE request must not carry plaintext needs" });
12847
+ return;
12655
12848
  }
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;
13121
- }
13122
- if (r.ask !== void 0) {
13123
- for (const f of ["context", "select", "points"]) {
13124
- 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)` });
13126
- }
13127
- return;
12849
+ if (r.ask !== void 0) {
12850
+ for (const f of ["context", "select", "points"]) {
12851
+ if (r[f] !== void 0)
12852
+ 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.` });
12853
+ }
12854
+ return;
13128
12855
  }
13129
12856
  if (r.needs !== void 0 || r.urgencyHint !== void 0)
13130
12857
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["needs"], message: "needs/urgencyHint belong to the simplified `ask` form \u2014 with a shaped request use points/urgency" });
@@ -13357,7 +13084,13 @@ var NotifyPlanUnitSchema2 = external_exports.object({
13357
13084
  proposal: external_exports.object({
13358
13085
  select: SelectShapeSchema2,
13359
13086
  options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
13360
- }).optional()
13087
+ }).optional(),
13088
+ /** What the broker READ this unit as wanting from the human (#952 layer 2): a `decision`
13089
+ * between alternatives, an `approval` the agent is blocked on, or `knowledge` it just
13090
+ * needs to know. Reported so the agent can correct a misread the same way it ratifies a
13091
+ * shape — the read RAISES (a decision always asks) and never silences a question the
13092
+ * agent declared (#731, #923). */
13093
+ wants: external_exports.enum(["decision", "approval", "knowledge"]).optional()
13361
13094
  });
13362
13095
  var NotifyPlanSchema2 = external_exports.object({
13363
13096
  units: external_exports.array(NotifyPlanUnitSchema2),
@@ -13604,6 +13337,15 @@ var HistoryItemSchema2 = external_exports.object({
13604
13337
  /** When you answered the agent's notification (agent→user only). */
13605
13338
  humanAckedAt: external_exports.string().nullable()
13606
13339
  });
13340
+ var ACTIVITY_LINES2 = 2;
13341
+ var ACTIVITY_LINE_MAX2 = 80;
13342
+ var AgentActivitySchema2 = external_exports.object({
13343
+ /** Oldest first, so the newest line is last — the one that replaces in place. */
13344
+ lines: external_exports.array(external_exports.string().max(ACTIVITY_LINE_MAX2)).max(ACTIVITY_LINES2),
13345
+ /** When the harness observed this tail. Its own timestamp, not the heartbeat's: a beat
13346
+ * that carries an UNCHANGED tail must not make a stalled agent look like it just moved. */
13347
+ at: external_exports.string().datetime()
13348
+ });
13607
13349
  var ConnectionSummarySchema2 = external_exports.object({
13608
13350
  /** The connection = the agent's token id (used to address a request). */
13609
13351
  id: external_exports.string(),
@@ -13637,6 +13379,11 @@ var ConnectionSummarySchema2 = external_exports.object({
13637
13379
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
13638
13380
  workspaces: external_exports.array(external_exports.string()).optional()
13639
13381
  }).optional(),
13382
+ /** The tail of this agent's working log, when a harness is driving it — the agent page's
13383
+ * live strip. Absent for anything the desktop harness isn't running (a hatched identity
13384
+ * used straight from a terminal emits no work events; the page says so rather than
13385
+ * drawing an empty box). */
13386
+ activity: AgentActivitySchema2.optional(),
13640
13387
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
13641
13388
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
13642
13389
  managed: external_exports.boolean()
@@ -13940,6 +13687,323 @@ var FeedbackOutcomeSchema2 = external_exports.object({
13940
13687
  childIds: external_exports.array(external_exports.string()).optional()
13941
13688
  });
13942
13689
 
13690
+ // src/paigy/activity.ts
13691
+ function shortenPaths(s) {
13692
+ return s.replace(/(?<![\w.@+-])(?:\/[\w.@+-]+){3,}/g, (p) => {
13693
+ const parts = p.split("/").filter(Boolean);
13694
+ return `\u2026/${parts.slice(-2).join("/")}`;
13695
+ });
13696
+ }
13697
+ function workLine(event) {
13698
+ const note = (event.note ?? "").split("\n").find((l) => l.trim()) ?? "";
13699
+ const line = shortenPaths([event.tool.trim(), note.trim()].filter(Boolean).join(" \u2014 ").replace(/\s+/g, " "));
13700
+ return line.length > ACTIVITY_LINE_MAX2 ? `${line.slice(0, ACTIVITY_LINE_MAX2 - 1)}\u2026` : line;
13701
+ }
13702
+ function pushWork(lines, line) {
13703
+ if (!line || lines[lines.length - 1] === line) return [...lines];
13704
+ return [...lines, line].slice(-ACTIVITY_LINES2);
13705
+ }
13706
+ function sameTail(a, b) {
13707
+ return a.length === b.length && a.every((l, i) => l === b[i]);
13708
+ }
13709
+
13710
+ // src/harness/session.ts
13711
+ import { spawn } from "child_process";
13712
+ import { existsSync as existsSync4 } from "fs";
13713
+ import { homedir as homedir4 } from "os";
13714
+ import { resolve as resolve2, delimiter as delimiter2 } from "path";
13715
+
13716
+ // src/harness/acp.ts
13717
+ var none = { events: [], writes: [] };
13718
+ function optionFor(options, decision) {
13719
+ const want = decision.allow ? "allow_once" : "reject_once";
13720
+ return options.find((o) => o.kind === want)?.optionId ?? null;
13721
+ }
13722
+ function createAcpDriver(opts) {
13723
+ return new AcpDriver(opts.cwd, opts.mode, opts.mcp ?? []);
13724
+ }
13725
+ var AcpDriver = class {
13726
+ constructor(cwd, mode, mcp = []) {
13727
+ this.cwd = cwd;
13728
+ this.mode = mode;
13729
+ this.mcp = mcp;
13730
+ }
13731
+ cwd;
13732
+ mode;
13733
+ mcp;
13734
+ nextId = 1;
13735
+ initId;
13736
+ sessionNewId;
13737
+ promptId;
13738
+ sessionId;
13739
+ queued = [];
13740
+ /** Options of each unanswered permission request, keyed by its JSON-RPC id. */
13741
+ pending = /* @__PURE__ */ new Map();
13742
+ /** Where the REPORT starts in `text` — everything before the LAST tool call is working
13743
+ * narration ("Now the API endpoints." → runs a tool), and it used to ship: the chunks
13744
+ * concatenate with no separator, so the owner's phone got "…find the repo.Now I have
13745
+ * the full picture. Writing the migration.Now…" as the opening paragraph of a finished
13746
+ * task (live, 2026-08-11 — "looks like a working log"). The narration's audience is the
13747
+ * terminal and host.log; what the agent composed AFTER its last tool call is the part
13748
+ * addressed to a human, and that is what leaves the machine. */
13749
+ reportFrom = 0;
13750
+ /** The turn being streamed: text accumulates, tools append, both flush on stopReason. */
13751
+ text = "";
13752
+ tools = [];
13753
+ /** The opening frame. Everything after is driven by responses in `handleLine`. */
13754
+ open() {
13755
+ this.initId = this.nextId++;
13756
+ return [
13757
+ frame({
13758
+ id: this.initId,
13759
+ method: "initialize",
13760
+ params: {
13761
+ protocolVersion: 2,
13762
+ // We are not an editor: no file services offered, the agent uses its own.
13763
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
13764
+ clientInfo: { name: "paigy-desktop", version: "0.0.0" }
13765
+ }
13766
+ })
13767
+ ];
13768
+ }
13769
+ /** Queue a prompt; it goes out when the session exists and no turn is running. */
13770
+ send(text) {
13771
+ this.queued.push(text);
13772
+ return this.flush();
13773
+ }
13774
+ /** Answer a PermissionEvent (ask mode). The id is the request's JSON-RPC id as a string. */
13775
+ respond(id, decision) {
13776
+ const request = this.pending.get(id);
13777
+ if (!request) return [];
13778
+ this.pending.delete(id);
13779
+ const optionId = optionFor(request.options, decision);
13780
+ return [
13781
+ optionId ? frame({ id: request.id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id: request.id, result: { outcome: { outcome: "cancelled" } } })
13782
+ ];
13783
+ }
13784
+ /** Cancel anything still blocked — called on stop so the process can exit cleanly. */
13785
+ close() {
13786
+ const writes = [...this.pending.values()].map(
13787
+ (r) => frame({ id: r.id, result: { outcome: { outcome: "cancelled" } } })
13788
+ );
13789
+ this.pending.clear();
13790
+ return writes;
13791
+ }
13792
+ handleLine(line) {
13793
+ const trimmed = line.trim();
13794
+ if (!trimmed) return none;
13795
+ let msg;
13796
+ try {
13797
+ msg = JSON.parse(trimmed);
13798
+ } catch {
13799
+ return none;
13800
+ }
13801
+ if (msg.method !== void 0) {
13802
+ return msg.id !== void 0 ? this.handleRequest(msg) : this.handleNotification(msg);
13803
+ }
13804
+ if (msg.id !== void 0) return this.handleResponse(msg);
13805
+ return none;
13806
+ }
13807
+ // ── responses to our requests ──
13808
+ handleResponse(msg) {
13809
+ if (msg.id === this.initId) {
13810
+ this.initId = void 0;
13811
+ if (msg.error) {
13812
+ return { events: [{ kind: "error", message: `initialize failed: ${msg.error.message}` }], writes: [] };
13813
+ }
13814
+ this.sessionNewId = this.nextId++;
13815
+ return {
13816
+ events: [],
13817
+ writes: [frame({ id: this.sessionNewId, method: "session/new", params: { cwd: this.cwd, mcpServers: this.mcp } })]
13818
+ };
13819
+ }
13820
+ if (msg.id === this.sessionNewId) {
13821
+ this.sessionNewId = void 0;
13822
+ const sessionId = msg.result?.sessionId;
13823
+ if (!sessionId) {
13824
+ return {
13825
+ events: [{ kind: "error", message: `session/new failed: ${msg.error?.message ?? "no sessionId"}` }],
13826
+ writes: []
13827
+ };
13828
+ }
13829
+ this.sessionId = sessionId;
13830
+ const value = this.mode === "bypass" ? "bypassPermissions" : "default";
13831
+ const writes = [
13832
+ frame({
13833
+ id: this.nextId++,
13834
+ method: "session/set_config_option",
13835
+ params: { sessionId, configId: "mode", value }
13836
+ }),
13837
+ frame({ id: this.nextId++, method: "session/set_mode", params: { sessionId, modeId: value } })
13838
+ ];
13839
+ writes.push(...this.flush());
13840
+ return { events: [], writes };
13841
+ }
13842
+ if (msg.id === this.promptId) {
13843
+ this.promptId = void 0;
13844
+ const events = [];
13845
+ const report = this.text.slice(this.reportFrom).trim() || this.text.trim();
13846
+ if (report || this.tools.length) {
13847
+ events.push({
13848
+ kind: "turn",
13849
+ role: "agent",
13850
+ text: report,
13851
+ ...this.tools.length ? { tools: [...this.tools] } : {}
13852
+ });
13853
+ }
13854
+ const result = report;
13855
+ const failed = msg.error !== void 0 || msg.result?.stopReason === "refusal";
13856
+ this.text = "";
13857
+ this.tools = [];
13858
+ this.reportFrom = 0;
13859
+ const writes = this.flush();
13860
+ if (!writes.length) {
13861
+ events.push({
13862
+ kind: "idle",
13863
+ ...result ? { result } : {},
13864
+ ...failed ? { failed: true } : {}
13865
+ });
13866
+ }
13867
+ return { events, writes };
13868
+ }
13869
+ return none;
13870
+ }
13871
+ // ── the agent talking to us ──
13872
+ handleNotification(msg) {
13873
+ if (msg.method !== "session/update") return none;
13874
+ const update = msg.params?.update;
13875
+ switch (update?.sessionUpdate) {
13876
+ case "agent_message_chunk":
13877
+ this.text += update.content?.text ?? "";
13878
+ return none;
13879
+ case "tool_call": {
13880
+ const title = update.title?.trim() || update.kind || "tool";
13881
+ this.tools.push(title);
13882
+ const note = this.text.slice(this.reportFrom).trim();
13883
+ this.reportFrom = this.text.length;
13884
+ return { events: [{ kind: "work", tool: title, ...note ? { note } : {} }], writes: [] };
13885
+ }
13886
+ default:
13887
+ return none;
13888
+ }
13889
+ }
13890
+ handleRequest(msg) {
13891
+ if (msg.method !== "session/request_permission") {
13892
+ return {
13893
+ events: [],
13894
+ writes: [frame({ id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } })]
13895
+ };
13896
+ }
13897
+ const id = msg.id;
13898
+ const options = msg.params?.options ?? [];
13899
+ const title = msg.params?.toolCall?.title?.trim() || "a tool call";
13900
+ const tool = msg.params?.toolCall?.kind ?? "tool";
13901
+ if (this.mode === "bypass") {
13902
+ const optionId = optionFor(options, { allow: true }) ?? optionFor(options, { allow: false });
13903
+ return {
13904
+ // The decision still goes through Paigy — as history, not a question. Bypass
13905
+ // means "don't stall the agent", never "don't tell the user".
13906
+ events: [{ kind: "turn", role: "agent", text: `auto-approved: ${title}`, tools: [tool] }],
13907
+ writes: [
13908
+ optionId ? frame({ id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id, result: { outcome: { outcome: "cancelled" } } })
13909
+ ]
13910
+ };
13911
+ }
13912
+ this.pending.set(String(id), { id, options });
13913
+ return {
13914
+ events: [{ kind: "permission", id: String(id), tool, summary: title }],
13915
+ writes: []
13916
+ };
13917
+ }
13918
+ /** Send the queued prompts as one turn, if the agent can take one right now. */
13919
+ flush() {
13920
+ if (!this.sessionId || this.promptId !== void 0 || !this.queued.length) return [];
13921
+ const blocks = this.queued.map((text) => ({ type: "text", text }));
13922
+ this.queued = [];
13923
+ this.promptId = this.nextId++;
13924
+ return [
13925
+ frame({
13926
+ id: this.promptId,
13927
+ method: "session/prompt",
13928
+ params: { sessionId: this.sessionId, prompt: blocks }
13929
+ })
13930
+ ];
13931
+ }
13932
+ };
13933
+ var frame = (body) => JSON.stringify({ jsonrpc: "2.0", ...body });
13934
+
13935
+ // src/harness/session.ts
13936
+ var ADAPTER_BIN = {
13937
+ claude: "claude-agent-acp",
13938
+ codex: "codex-acp",
13939
+ agy: "agy"
13940
+ };
13941
+ function splitLines(buffer, chunk) {
13942
+ const combined = buffer + chunk;
13943
+ const parts = combined.split("\n");
13944
+ const rest = parts.pop() ?? "";
13945
+ return { lines: parts.filter((l) => l.trim()), rest };
13946
+ }
13947
+ function startSession(opts) {
13948
+ const cwd = resolve2(opts.cwd.replace(/^~(?=$|\/)/, homedir4()));
13949
+ if (!existsSync4(cwd)) {
13950
+ queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
13951
+ }
13952
+ const child = (opts.spawnFn ?? spawn)(opts.bin ?? ADAPTER_BIN[opts.harness], [], {
13953
+ cwd,
13954
+ // The adapter shells out to its vendor CLI (`claude`, `codex`), and a GUI- or
13955
+ // launchd-launched process's PATH won't have it — probe dirs plus the RUNNING
13956
+ // node's own bin dir (nvm installs the adapters next to node; launchd's bare
13957
+ // PATH knows neither — live catch 2026-08-04: the first slot-wake ENOENT'd).
13958
+ env: {
13959
+ ...process.env,
13960
+ PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
13961
+ ...opts.token ? { PAIGY_TOKEN: opts.token } : {}
13962
+ },
13963
+ stdio: ["pipe", "pipe", "pipe"]
13964
+ });
13965
+ const write = (frames) => {
13966
+ for (const f of frames) child.stdin?.write(`${f}
13967
+ `);
13968
+ };
13969
+ child.stderr?.on("data", (chunk) => {
13970
+ const text = String(chunk).trim();
13971
+ if (text) opts.onEvent({ kind: "error", message: text });
13972
+ });
13973
+ child.on("exit", (code) => {
13974
+ opts.onEvent({ kind: "idle", failed: code !== 0, ...code ? { result: `exited ${code}` } : {} });
13975
+ opts.onExit?.();
13976
+ });
13977
+ child.on("error", (e) => {
13978
+ opts.onEvent({ kind: "error", message: e.message });
13979
+ opts.onExit?.();
13980
+ });
13981
+ const driver = createAcpDriver({ cwd, mode: opts.mode, ...opts.mcp ? { mcp: opts.mcp } : {} });
13982
+ let buffer = "";
13983
+ child.stdout?.on("data", (chunk) => {
13984
+ const { lines, rest } = splitLines(buffer, String(chunk));
13985
+ buffer = rest;
13986
+ for (const line of lines) {
13987
+ const { events, writes } = driver.handleLine(line);
13988
+ write(writes);
13989
+ for (const event of events) opts.onEvent(event);
13990
+ }
13991
+ });
13992
+ write(driver.open());
13993
+ return {
13994
+ send(text) {
13995
+ write(driver.send(text));
13996
+ },
13997
+ respond(id, decision) {
13998
+ write(driver.respond(id, decision));
13999
+ },
14000
+ stop() {
14001
+ write(driver.close());
14002
+ child.kill();
14003
+ }
14004
+ };
14005
+ }
14006
+
13943
14007
  // src/paigy/conversation.ts
13944
14008
  function endsWithQuestion(text) {
13945
14009
  if (!text) return false;
@@ -14205,6 +14269,7 @@ var clip = (s) => (s.length > 120 ? `${s.slice(0, 117)}\u2026` : s) || "(no text
14205
14269
  // src/run.ts
14206
14270
  function runHarness(opts) {
14207
14271
  let running = true;
14272
+ let tail = [];
14208
14273
  const state = { ...opts.exclusive ? { exclusive: true } : {} };
14209
14274
  let session = null;
14210
14275
  const asMe = { token: opts.token };
@@ -14234,6 +14299,7 @@ function runHarness(opts) {
14234
14299
  }
14235
14300
  if (event.kind === "work") {
14236
14301
  opts.log(`\u2699 ${event.tool}${event.note ? ` \u2014 ${event.note.split("\n")[0] ?? ""}` : ""}`);
14302
+ tail = pushWork(tail, workLine(event));
14237
14303
  return;
14238
14304
  }
14239
14305
  const { parentId } = await mirror(event, state, deps);
@@ -14241,6 +14307,7 @@ function runHarness(opts) {
14241
14307
  if (event.kind === "turn") opts.log(`${event.role}: ${event.text.split("\n")[0] ?? ""}`);
14242
14308
  if (event.kind === "idle") {
14243
14309
  state.resting = true;
14310
+ tail = [];
14244
14311
  if (endsWithQuestion(event.result)) {
14245
14312
  const local = await opts.localAsk?.question?.(event.result ?? "") ?? null;
14246
14313
  if (local?.trim() && session && running) {
@@ -14278,8 +14345,10 @@ function runHarness(opts) {
14278
14345
  session?.send(text);
14279
14346
  },
14280
14347
  working: () => running && state.resting !== true,
14348
+ tail: () => [...tail],
14281
14349
  stop() {
14282
14350
  running = false;
14351
+ tail = [];
14283
14352
  cancelAsks(state);
14284
14353
  session?.stop();
14285
14354
  session = null;
@@ -14397,6 +14466,27 @@ function startHost(opts) {
14397
14466
  opts.log(`\u25B6 woke ${label} (${harness}) \u2014 work was waiting in ${workspace}`);
14398
14467
  }
14399
14468
  }
14469
+ const ACTIVITY_MS = 2e3;
14470
+ const published = /* @__PURE__ */ new Map();
14471
+ const streamActivity = () => {
14472
+ const publishTail = (token, lines) => {
14473
+ void heartbeat(void 0, { token, activity: { lines, at: (/* @__PURE__ */ new Date()).toISOString() } }).catch(() => {
14474
+ });
14475
+ };
14476
+ for (const [key, r] of runs) {
14477
+ if (!r.token) continue;
14478
+ const lines = r.run.tail();
14479
+ const was = published.get(key);
14480
+ if (was && sameTail(was.lines, lines)) continue;
14481
+ published.set(key, { token: r.token, lines });
14482
+ publishTail(r.token, lines);
14483
+ }
14484
+ for (const [key, was] of published) {
14485
+ if (runs.has(key)) continue;
14486
+ published.delete(key);
14487
+ if (was.lines.length > 0) publishTail(was.token, []);
14488
+ }
14489
+ };
14400
14490
  const publish = () => {
14401
14491
  try {
14402
14492
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: Date.now(), roster: api.roster() }));
@@ -14410,6 +14500,7 @@ function startHost(opts) {
14410
14500
  void sweepSlots();
14411
14501
  publish();
14412
14502
  }, 5e3);
14503
+ const activityTick = setInterval(streamActivity, ACTIVITY_MS);
14413
14504
  const stopSessions = () => {
14414
14505
  for (const { run, label } of runs.values()) {
14415
14506
  run.stop();
@@ -14447,6 +14538,8 @@ function startHost(opts) {
14447
14538
  clearInterval(pulse);
14448
14539
  clearInterval(spawnPoll);
14449
14540
  stopSessions();
14541
+ streamActivity();
14542
+ clearInterval(activityTick);
14450
14543
  try {
14451
14544
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: 0, roster: [] }));
14452
14545
  } catch {