@paigy/harness 0.2.12 → 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 (4) hide show
  1. package/README.md +29 -1
  2. package/dist/cli.js +714 -132
  3. package/dist/main.js +886 -347
  4. package/package.json +15 -13
package/dist/main.js CHANGED
@@ -4577,11 +4577,13 @@ var require_lib = __commonJS({
4577
4577
  // src/main.ts
4578
4578
  import { app, BrowserWindow, dialog, ipcMain } from "electron";
4579
4579
  import { spawn as spawn2 } from "child_process";
4580
+ import { readFileSync as readFileSync4 } from "fs";
4580
4581
  import { fileURLToPath } from "url";
4581
4582
  import { dirname as dirname4, join as join5 } from "path";
4582
4583
 
4583
4584
  // ../../packages/sdk/dist/index.js
4584
4585
  import { createRequire as __sdkCreateRequire } from "module";
4586
+ import { randomUUID } from "crypto";
4585
4587
 
4586
4588
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
4587
4589
  var external_exports = {};
@@ -8625,7 +8627,8 @@ var coerce = {
8625
8627
  var NEVER = INVALID;
8626
8628
 
8627
8629
  // ../../packages/sdk/dist/index.js
8628
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
8630
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
8631
+ import { randomUUID as randomUUID2 } from "crypto";
8629
8632
  import { homedir } from "os";
8630
8633
  import { join } from "path";
8631
8634
  var require2 = __sdkCreateRequire(import.meta.url);
@@ -10891,16 +10894,25 @@ async function proxy() {
10891
10894
  if (!PROXY_ENV.some((k) => process.env[k])) return void 0;
10892
10895
  return agent ??= new (await import("./undici-GCFUZISI.js")).EnvHttpProxyAgent();
10893
10896
  }
10897
+ var INSTANCE_ID = randomUUID();
10898
+ var SESSION_ID = process.env.PAIGY_SESSION_ID ?? process.env.CLAUDE_CODE_SESSION_ID ?? INSTANCE_ID;
10894
10899
  async function reach(url, init) {
10895
10900
  try {
10896
- return await fetch(url, { ...init, dispatcher: await proxy() });
10901
+ const headers = {
10902
+ ...init?.headers,
10903
+ "x-paigy-instance": INSTANCE_ID,
10904
+ "x-paigy-session": SESSION_ID
10905
+ };
10906
+ return await fetch(url, { ...init, headers, dispatcher: await proxy() });
10897
10907
  } catch (e) {
10898
10908
  throw new Error(`${NETWORK_MSG} (${e?.message ?? String(e)})`);
10899
10909
  }
10900
10910
  }
10901
10911
  var ContextSchema = external_exports.object({
10902
10912
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
10903
- description: external_exports.array(external_exports.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
10913
+ description: external_exports.array(external_exports.string().min(1)).describe(
10914
+ "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."
10915
+ )
10904
10916
  });
10905
10917
  var ParticipantSchema = external_exports.object({
10906
10918
  kind: external_exports.enum(["human", "agent"]),
@@ -11047,6 +11059,14 @@ var NotifyRequestSchema = external_exports.object({
11047
11059
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
11048
11060
  "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."
11049
11061
  ),
11062
+ /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
11063
+ * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
11064
+ * holding by default would charge every quiet claim that minute before any agent could
11065
+ * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
11066
+ * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
11067
+ confirm: external_exports.boolean().optional().describe(
11068
+ "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'."
11069
+ ),
11050
11070
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
11051
11071
  * choice. Outranks waiting in both directions: 'call' rings even for a
11052
11072
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -11080,7 +11100,7 @@ var NotifyRequestSchema = external_exports.object({
11080
11100
  if (r.ask !== void 0) {
11081
11101
  for (const f of ["context", "select", "points"]) {
11082
11102
  if (r[f] !== void 0)
11083
- 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.` });
11084
11104
  }
11085
11105
  return;
11086
11106
  }
@@ -11105,35 +11125,45 @@ function normalizeWaiting(req) {
11105
11125
  blocking: req.blocking || waiting === "hard"
11106
11126
  };
11107
11127
  }
11108
- var DERIVE_CHUNKS_MAX = 8;
11109
- var DERIVE_CHUNK_MAX = 300;
11110
- function chunkAsk(text, max = DERIVE_CHUNK_MAX, cap = DERIVE_CHUNKS_MAX) {
11128
+ function unitsOf(text) {
11129
+ const spans = [];
11130
+ const re = /\n\s*\n+/g;
11131
+ let cursor = 0;
11132
+ const push = (from, to) => {
11133
+ const slice = text.slice(from, to);
11134
+ const lead = slice.length - slice.trimStart().length;
11135
+ const tail = slice.length - slice.trimEnd().length;
11136
+ if (from + lead < to - tail) spans.push({ start: from + lead, end: to - tail });
11137
+ };
11138
+ for (let m = re.exec(text); m; m = re.exec(text)) {
11139
+ push(cursor, m.index);
11140
+ cursor = m.index + m[0].length;
11141
+ }
11142
+ push(cursor, text.length);
11143
+ return spans;
11144
+ }
11145
+ function headline(text) {
11146
+ const line = text.split("\n").map((l) => l.trim()).filter((l) => l && !/^`{3,}/.test(l)).map((l) => l.replace(/^(?:#{1,6}|[-*>]|\d{1,3}\.)\s+/, "")).find(Boolean) ?? "";
11147
+ return (/^[^.!?\n]+[.!?]?/.exec(line)?.[0] ?? line).trim();
11148
+ }
11149
+ function bodyAfterHeadline(text) {
11111
11150
  const body = text.trim();
11112
- if (!body) return [];
11113
- const sentences = body.match(/[^.!?]+[.!?]*\s*/g) ?? [body];
11114
- const chunks = [];
11115
- for (const raw of sentences) {
11116
- const s = raw.trim();
11117
- if (!s) continue;
11118
- const last = chunks[chunks.length - 1];
11119
- if (last !== void 0 && `${last} ${s}`.length <= max) chunks[chunks.length - 1] = `${last} ${s}`;
11120
- else chunks.push(s);
11121
- }
11122
- if (chunks.length <= cap) return chunks;
11123
- return [...chunks.slice(0, cap - 1), chunks.slice(cap - 1).join(" ")];
11151
+ const title = headline(body);
11152
+ const rest = body.slice(title.length).replace(/^[\s.!?—–-]+/, "").trim();
11153
+ if (!rest) return { title, description: [] };
11154
+ const chunks = unitsOf(rest).map((u) => rest.slice(u.start, u.end)).filter(Boolean);
11155
+ return { title, description: chunks.length ? chunks : [rest] };
11124
11156
  }
11125
11157
  function deriveAsk(req) {
11126
11158
  req = normalizeWaiting(req);
11127
11159
  if (!req.ask) return req;
11128
11160
  const text = req.ask.trim();
11129
- const firstSentence = (/^[^.!?\n]+[.!?]?/.exec(text)?.[0] ?? text).trim();
11130
- const title = firstSentence.length > 90 ? `${firstSentence.slice(0, 87).trimEnd()}\u2026` : firstSentence;
11131
11161
  const hinted = req.urgencyHint === "now" ? "call" : req.urgencyHint === "soon" ? "banner" : req.urgencyHint === "whenever" ? "inbox" : req.urgency;
11132
11162
  const urgency = req.channel === "call" ? "call" : req.channel === "message" && hinted === "call" ? "banner" : hinted;
11133
11163
  const { ask: _ask, needs, urgencyHint: _hint, channel: _channel, ...rest } = req;
11134
11164
  return {
11135
11165
  ...rest,
11136
- context: { title, description: chunkAsk(text) },
11166
+ context: bodyAfterHeadline(text),
11137
11167
  // Options riding alongside the ask (#575: pixels can't be prose) floor to a
11138
11168
  // single pick — the model broker may upgrade to many/rank from the wording.
11139
11169
  select: req.options?.length ? "one" : "text",
@@ -11323,6 +11353,40 @@ var NotifyResponseSchema = external_exports.object({
11323
11353
  answer: UserAnswerSchema.optional(),
11324
11354
  answeredAt: external_exports.string().datetime().optional()
11325
11355
  });
11356
+ var NotifyPlanUnitSchema = external_exports.object({
11357
+ notificationId: external_exports.string(),
11358
+ /** The unit's own heading, so the agent can tell which of its paragraphs this became. */
11359
+ title: external_exports.string(),
11360
+ /** How loudly this unit was arbitrated to arrive — per unit, which is the point of units. */
11361
+ level: NotifyLevelSchema,
11362
+ /** Answered from something the user already decided: nobody is interrupted, and a trail card
11363
+ * says so. The agent should not wait on this one. */
11364
+ settled: external_exports.literal(true).optional(),
11365
+ /** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
11366
+ * agent — nothing here changed the ask, and ignoring it costs nothing. */
11367
+ needs: external_exports.array(external_exports.enum(["options", "visuals"])).optional(),
11368
+ /** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
11369
+ * split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
11370
+ * broker that DECIDES that destroys the only fact separating a statement from a real ask
11371
+ * (#731), so it is offered, never applied: the unit is stored `text` until the agent
11372
+ * confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
11373
+ proposal: external_exports.object({
11374
+ select: SelectShapeSchema,
11375
+ options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).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()
11383
+ });
11384
+ var NotifyPlanSchema = external_exports.object({
11385
+ units: external_exports.array(NotifyPlanUnitSchema),
11386
+ /** Which unit is this arrival's ONE interruption (units-design.md D23). Absent means nobody
11387
+ * was interrupted — every unit was either settled or quiet enough to sit in the inbox. */
11388
+ speaks: external_exports.string().optional()
11389
+ });
11326
11390
  var UserResponseSchema = external_exports.object({
11327
11391
  requestId: external_exports.string(),
11328
11392
  answer: UserAnswerSchema,
@@ -11377,6 +11441,20 @@ var InboxItemSchema = external_exports.object({
11377
11441
  /** The conversation thread + connection this item lives on. Present on the replied
11378
11442
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
11379
11443
  parentId: external_exports.string().optional(),
11444
+ /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
11445
+ * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
11446
+ * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
11447
+ * accumulates every message an agent ever sent, so grouping by it renders a day of
11448
+ * unrelated updates as a single "12-part request". Absent on rows written before the
11449
+ * `asks` table, and on anything that never went through `notify` — both fall back to the
11450
+ * thread, which is what the client did for all rows until now. */
11451
+ askId: external_exports.string().optional(),
11452
+ /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
11453
+ * shares one `created_at` to the microsecond, so without it the author's order is
11454
+ * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
11455
+ * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
11456
+ * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
11457
+ seq: external_exports.number().int().optional(),
11380
11458
  tokenId: external_exports.string().optional(),
11381
11459
  status: NotifyStatusSchema,
11382
11460
  context: ContextSchema,
@@ -11387,6 +11465,16 @@ var InboxItemSchema = external_exports.object({
11387
11465
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
11388
11466
  * between them could not express a call as a sequence. `question: null` is a real turn —
11389
11467
  * a status update stays a statement instead of being shaped into a yes/no. */
11468
+ /** Does this claim want an ANSWER, or is it telling you something? Written per row from
11469
+ * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
11470
+ * its acknowledge affordance: without it a status update offers a text box and a dismiss,
11471
+ * and neither of those is "got it" (owner, 2026-08-10). */
11472
+ asks: external_exports.boolean().optional(),
11473
+ /** When a live process last pulsed for this row's agent — the liveness input for
11474
+ * "working requires a pulse" (#928): the list said "Working…" from agent_state alone
11475
+ * while the party called the same dead claim stalled. Absent = no token/no data,
11476
+ * which must never CLAIM stalled. */
11477
+ lastSeenAt: external_exports.string().optional(),
11390
11478
  agenda: external_exports.array(AgendaTurnSchema).optional(),
11391
11479
  /** On a replied detail (#397): the next steps the user attached to the answer
11392
11480
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -11411,6 +11499,23 @@ var InboxItemSchema = external_exports.object({
11411
11499
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
11412
11500
  error: external_exports.string().optional(),
11413
11501
  clarifies: external_exports.string().optional(),
11502
+ /** The ring ladder ran out while this was still pending — we tried to reach you and
11503
+ * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
11504
+ * agent with nothing to say, which the roster drew identically until now: "nothing to
11505
+ * say" and "gave up saying it" are opposite situations wearing the same face
11506
+ * (navigation-design.md, gap 1). False for anything that never rang. */
11507
+ gaveUp: external_exports.boolean().default(false),
11508
+ /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
11509
+ * Absent for anything never delivered through a push, and for older rows written before
11510
+ * the reason was recorded. Deliberately a debug affordance, shown small (owner,
11511
+ * 2026-08-07) — its real job is to give "this didn't need a call" something to be
11512
+ * feedback ABOUT. */
11513
+ why: external_exports.object({
11514
+ asked: NotifyLevelSchema,
11515
+ got: NotifyLevelSchema,
11516
+ because: external_exports.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
11517
+ line: external_exports.string()
11518
+ }).optional(),
11414
11519
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
11415
11520
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
11416
11521
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -11521,6 +11626,15 @@ var HistoryItemSchema = external_exports.object({
11521
11626
  /** When you answered the agent's notification (agent→user only). */
11522
11627
  humanAckedAt: external_exports.string().nullable()
11523
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
+ });
11524
11638
  var ConnectionSummarySchema = external_exports.object({
11525
11639
  /** The connection = the agent's token id (used to address a request). */
11526
11640
  id: external_exports.string(),
@@ -11532,6 +11646,14 @@ var ConnectionSummarySchema = external_exports.object({
11532
11646
  provider: external_exports.string().nullable(),
11533
11647
  /** The pairing's assigned voice (#462); null = the default voice. */
11534
11648
  voice: VoiceKeySchema.nullable(),
11649
+ /** The LOUDEST this agent may ever reach you — a ceiling on `NOTIFY_LADDER`, set by the
11650
+ * user on the agent's own page. null = no ceiling (today's behaviour for every
11651
+ * connection). Android binds importance to a relationship rather than to each message,
11652
+ * and that is the thing our roster could not say: "Marlow may always call me; Otto
11653
+ * never may" (navigation-design.md, gap 2). Clamped in `arbitrateLevel`, so it binds
11654
+ * every surface at once and outranks even `sessionMode: all_calls` — a mode the user
11655
+ * set once must not overrule a rule they set about one agent. */
11656
+ reach: NotifyLevelSchema.nullable().optional(),
11535
11657
  createdAt: external_exports.string().datetime(),
11536
11658
  /** Most recent notification on this connection, either direction. Null = no contact yet.
11537
11659
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -11546,10 +11668,49 @@ var ConnectionSummarySchema = external_exports.object({
11546
11668
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
11547
11669
  workspaces: external_exports.array(external_exports.string()).optional()
11548
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(),
11549
11676
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
11550
11677
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
11551
11678
  managed: external_exports.boolean()
11552
11679
  });
11680
+ var MoveRingSchema = external_exports.enum(["home", "travels", "retired", "quarantined"]);
11681
+ var MoveSchema = external_exports.object({
11682
+ id: external_exports.string(),
11683
+ /** The reusable question, as distill normalized it. */
11684
+ question: external_exports.string(),
11685
+ /** The operative ruling. Editable by the user (PATCH) — which resets the ledger. */
11686
+ answer: external_exports.string(),
11687
+ /** The user's stated reason, when they gave one. Null = inherently narrow: the judge is
11688
+ * told so, and the ruling only derives essentially the same question in the same scope. */
11689
+ rationale: external_exports.string().nullable(),
11690
+ /** Where the ruling lives: a repo/workspace, or 'global'. */
11691
+ scope: external_exports.string(),
11692
+ ring: MoveRingSchema,
11693
+ /** True = the user pinned it with `always` (travel granted by hand, not by evidence). */
11694
+ pinned: external_exports.boolean(),
11695
+ /** True = a pin the user placed was BROKEN by later counter-evidence. Surfaced so the
11696
+ * break is visible instead of a pin silently disappearing. */
11697
+ pinBroken: external_exports.boolean(),
11698
+ /** When the ruling was distilled. */
11699
+ learnedAt: external_exports.string(),
11700
+ /** Last time it answered an ask. Null = never fired. */
11701
+ lastUsedAt: external_exports.string().nullable(),
11702
+ /** How many asks it has answered. Instrumentation — deliberately NOT an input to the
11703
+ * evidence curve: firing says the question keeps arising, not that the ruling is right. */
11704
+ usedCount: external_exports.number(),
11705
+ /** Ledger: outcomes that said it held up. Saturating — the tenth is worth almost nothing. */
11706
+ confirms: external_exports.number(),
11707
+ /** Ledger: contradictions, in signal units (a full override = 1, weaker signals less).
11708
+ * Linear and priced above the entire confirmation budget, so any full counter wins. */
11709
+ counters: external_exports.number(),
11710
+ /** The agent that asked the question this move came from, when known. Null for a move
11711
+ * distilled from a clarify ruling (those carry no agent) or one whose source rows are gone. */
11712
+ learnedFrom: external_exports.object({ id: external_exports.string(), name: external_exports.string() }).nullable()
11713
+ });
11553
11714
  var CreateRequestSchema = external_exports.object({
11554
11715
  /** The connection (token id) to send to, from GET /api/tokens. */
11555
11716
  tokenId: external_exports.string(),
@@ -11756,6 +11917,17 @@ var DeviceTokenSchema = external_exports.object({
11756
11917
  * a default silly name). */
11757
11918
  name: external_exports.string(),
11758
11919
  device: external_exports.string().nullable(),
11920
+ /** The pairing's assigned voice, cached so the desktop can seed the SAME face the phone
11921
+ * draws — voice is the third ingredient of a hatchling's build (party/traits.ts). */
11922
+ voice: external_exports.string().nullable().optional(),
11923
+ /** The token's server-side id — the face's COLOUR anchor, and the only seed ingredient
11924
+ * that survives a rename. Cached by the host's identity beat. */
11925
+ token_id: external_exports.string().nullable().optional(),
11926
+ /** WHERE this identity works — the folder a wake should land it in. Written by the host
11927
+ * at spawn and by `paigy-harness handoff` from a live terminal. Without it every wake
11928
+ * landed in the FIRST granted workspace and the agent rediscovered its own repo from
11929
+ * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
11930
+ workspace: external_exports.string().nullable().optional(),
11759
11931
  phone_reveal: PairingRevealSchema.nullable().optional(),
11760
11932
  // present once the phone reveals
11761
11933
  uik_pub: external_exports.string().nullable().optional()
@@ -11782,6 +11954,8 @@ var NotificationFeedbackKindSchema = external_exports.enum([
11782
11954
  // "There should be a picture or design here."
11783
11955
  "should_have_called",
11784
11956
  // "Don't put this in a banner — ring me for something like this."
11957
+ "should_have_messaged",
11958
+ // the inverse: "that didn't deserve a ring — a message would do."
11785
11959
  "other"
11786
11960
  // anything else — the note carries it.
11787
11961
  ]);
@@ -12078,7 +12252,11 @@ function open(envelope, myKeyId, mySecretKeyB64) {
12078
12252
  if (!eqCt(sealedCanon, expected)) throw new Error("header mismatch (tampered metadata)");
12079
12253
  return { header: envelope.hdr, body };
12080
12254
  }
12081
- var AGENT_NAME = process.env.PAIGY_AGENT ?? "mcp-agent";
12255
+ function sessionSlot(sessionId) {
12256
+ const id = sessionId ?? process.env.PAIGY_SESSION_ID ?? process.env.CLAUDE_CODE_SESSION_ID ?? randomUUID2();
12257
+ return `session:${id.slice(0, 8)}`;
12258
+ }
12259
+ var AGENT_NAME = process.env.PAIGY_AGENT || sessionSlot();
12082
12260
  var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
12083
12261
  var KEY_PATH = join(homedir(), ".paigy", "key.json");
12084
12262
  function readTokenFile() {
@@ -12093,10 +12271,45 @@ function readTokenFile() {
12093
12271
  return {};
12094
12272
  }
12095
12273
  }
12096
- function saveToken(token, agent2 = AGENT_NAME) {
12097
- const slots = { ...readTokenFile(), [agent2]: token };
12274
+ function withTokenLock(mutate) {
12275
+ const lock = TOKEN_PATH + ".lock";
12276
+ const spin = new Int32Array(new SharedArrayBuffer(4));
12098
12277
  mkdirSync(join(homedir(), ".paigy"), { recursive: true });
12099
- writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
12278
+ for (const deadline = Date.now() + 5e3; Date.now() < deadline; ) {
12279
+ try {
12280
+ closeSync(openSync(lock, "wx"));
12281
+ break;
12282
+ } catch {
12283
+ const held = statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now();
12284
+ if (Date.now() - held > 5e3) rmSync(lock, { force: true });
12285
+ else Atomics.wait(spin, 0, 0, 25);
12286
+ }
12287
+ }
12288
+ try {
12289
+ return mutate();
12290
+ } finally {
12291
+ rmSync(lock, { force: true });
12292
+ }
12293
+ }
12294
+ function saveToken(token, agent2 = AGENT_NAME) {
12295
+ withTokenLock(() => {
12296
+ const slots = { ...readTokenFile(), [agent2]: token };
12297
+ writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
12298
+ });
12299
+ }
12300
+ function updateSlot(agent2, patch) {
12301
+ return withTokenLock(() => {
12302
+ const slots = readTokenFile();
12303
+ const existing = slots[agent2];
12304
+ if (!existing) return false;
12305
+ slots[agent2] = { ...existing, ...patch };
12306
+ writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
12307
+ return true;
12308
+ });
12309
+ }
12310
+ function slotIdentity(agent2) {
12311
+ const t = readTokenFile()[agent2];
12312
+ return { name: t?.name ?? null, voice: t?.voice ?? null, tokenId: t?.token_id ?? null, workspace: t?.workspace ?? null };
12100
12313
  }
12101
12314
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12102
12315
  function readToken(agent2 = AGENT_NAME) {
@@ -12282,10 +12495,14 @@ async function claimSessions(opts = {}) {
12282
12495
  return (await res.json()).sessions;
12283
12496
  }
12284
12497
  async function heartbeat(runtime, opts = {}) {
12498
+ const body = {
12499
+ ...runtime !== void 0 ? { runtime } : {},
12500
+ ...opts.activity !== void 0 ? { activity: opts.activity } : {}
12501
+ };
12285
12502
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/presence`, {
12286
12503
  method: "POST",
12287
12504
  headers: { "content-type": "application/json", authorization: `Bearer ${authToken(opts.token)}` },
12288
- ...runtime !== void 0 ? { body: JSON.stringify({ runtime }) } : {}
12505
+ ...Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}
12289
12506
  }));
12290
12507
  if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
12291
12508
  }
@@ -12301,7 +12518,7 @@ async function setTaskState(notificationId, state, opts = {}) {
12301
12518
 
12302
12519
  // src/main.ts
12303
12520
  var import_qrcode = __toESM(require_lib(), 1);
12304
- import { hostname } from "os";
12521
+ import { homedir as homedir6, hostname } from "os";
12305
12522
 
12306
12523
  // src/harness/catalog.ts
12307
12524
  import { spawnSync } from "child_process";
@@ -12328,6 +12545,14 @@ var CATALOG = [
12328
12545
  loginHint: "Run `codex login` to authenticate.",
12329
12546
  installCli: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
12330
12547
  installAdapter: "npm install -g @agentclientprotocol/codex-acp"
12548
+ },
12549
+ {
12550
+ name: "agy",
12551
+ label: "Antigravity",
12552
+ cli: "agy",
12553
+ authProbe: ["agy", "help"],
12554
+ loginHint: "Install and set up Antigravity (`agy`).",
12555
+ installCli: "curl -fsSL https://antigravity.dev/install.sh | bash"
12331
12556
  }
12332
12557
  ];
12333
12558
  function probeDirs(home = homedir2()) {
@@ -12419,295 +12644,24 @@ function removeWorkspace(dir, deps) {
12419
12644
  writeFileSync2(deps.file, JSON.stringify(all, null, 2));
12420
12645
  return all;
12421
12646
  }
12422
-
12423
- // src/harness/session.ts
12424
- import { spawn } from "child_process";
12425
- import { existsSync as existsSync4 } from "fs";
12426
- import { homedir as homedir4 } from "os";
12427
- import { resolve as resolve2, delimiter as delimiter2 } from "path";
12428
-
12429
- // src/harness/acp.ts
12430
- var none = { events: [], writes: [] };
12431
- function optionFor(options, decision) {
12432
- const want = decision.allow ? "allow_once" : "reject_once";
12433
- return options.find((o) => o.kind === want)?.optionId ?? null;
12434
- }
12435
- function createAcpDriver(opts) {
12436
- return new AcpDriver(opts.cwd, opts.mode, opts.mcp ?? []);
12647
+ function allowed(cwd, deps) {
12648
+ const target = resolve(cwd.replace(/^~(?=$|\/)/, homedir3()));
12649
+ return listWorkspaces(deps).some((w) => target === w || target.startsWith(`${w}/`));
12437
12650
  }
12438
- var AcpDriver = class {
12439
- constructor(cwd, mode, mcp = []) {
12440
- this.cwd = cwd;
12441
- this.mode = mode;
12442
- this.mcp = mcp;
12443
- }
12444
- cwd;
12445
- mode;
12446
- mcp;
12447
- nextId = 1;
12448
- initId;
12449
- sessionNewId;
12450
- promptId;
12451
- sessionId;
12452
- queued = [];
12453
- /** Options of each unanswered permission request, keyed by its JSON-RPC id. */
12454
- pending = /* @__PURE__ */ new Map();
12455
- /** The turn being streamed: text accumulates, tools append, both flush on stopReason. */
12456
- text = "";
12457
- tools = [];
12458
- /** The opening frame. Everything after is driven by responses in `handleLine`. */
12459
- open() {
12460
- this.initId = this.nextId++;
12461
- return [
12462
- frame({
12463
- id: this.initId,
12464
- method: "initialize",
12465
- params: {
12466
- protocolVersion: 2,
12467
- // We are not an editor: no file services offered, the agent uses its own.
12468
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
12469
- clientInfo: { name: "paigy-desktop", version: "0.0.0" }
12470
- }
12471
- })
12472
- ];
12473
- }
12474
- /** Queue a prompt; it goes out when the session exists and no turn is running. */
12475
- send(text) {
12476
- this.queued.push(text);
12477
- return this.flush();
12478
- }
12479
- /** Answer a PermissionEvent (ask mode). The id is the request's JSON-RPC id as a string. */
12480
- respond(id, decision) {
12481
- const request = this.pending.get(id);
12482
- if (!request) return [];
12483
- this.pending.delete(id);
12484
- const optionId = optionFor(request.options, decision);
12485
- return [
12486
- optionId ? frame({ id: request.id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id: request.id, result: { outcome: { outcome: "cancelled" } } })
12487
- ];
12488
- }
12489
- /** Cancel anything still blocked — called on stop so the process can exit cleanly. */
12490
- close() {
12491
- const writes = [...this.pending.values()].map(
12492
- (r) => frame({ id: r.id, result: { outcome: { outcome: "cancelled" } } })
12493
- );
12494
- this.pending.clear();
12495
- return writes;
12496
- }
12497
- handleLine(line) {
12498
- const trimmed = line.trim();
12499
- if (!trimmed) return none;
12500
- let msg;
12501
- try {
12502
- msg = JSON.parse(trimmed);
12503
- } catch {
12504
- return none;
12505
- }
12506
- if (msg.method !== void 0) {
12507
- return msg.id !== void 0 ? this.handleRequest(msg) : this.handleNotification(msg);
12508
- }
12509
- if (msg.id !== void 0) return this.handleResponse(msg);
12510
- return none;
12511
- }
12512
- // ── responses to our requests ──
12513
- handleResponse(msg) {
12514
- if (msg.id === this.initId) {
12515
- this.initId = void 0;
12516
- if (msg.error) {
12517
- return { events: [{ kind: "error", message: `initialize failed: ${msg.error.message}` }], writes: [] };
12518
- }
12519
- this.sessionNewId = this.nextId++;
12520
- return {
12521
- events: [],
12522
- writes: [frame({ id: this.sessionNewId, method: "session/new", params: { cwd: this.cwd, mcpServers: this.mcp } })]
12523
- };
12524
- }
12525
- if (msg.id === this.sessionNewId) {
12526
- this.sessionNewId = void 0;
12527
- const sessionId = msg.result?.sessionId;
12528
- if (!sessionId) {
12529
- return {
12530
- events: [{ kind: "error", message: `session/new failed: ${msg.error?.message ?? "no sessionId"}` }],
12531
- writes: []
12532
- };
12533
- }
12534
- this.sessionId = sessionId;
12535
- const value = this.mode === "bypass" ? "bypassPermissions" : "default";
12536
- const writes = [
12537
- frame({
12538
- id: this.nextId++,
12539
- method: "session/set_config_option",
12540
- params: { sessionId, configId: "mode", value }
12541
- }),
12542
- frame({ id: this.nextId++, method: "session/set_mode", params: { sessionId, modeId: value } })
12543
- ];
12544
- writes.push(...this.flush());
12545
- return { events: [], writes };
12546
- }
12547
- if (msg.id === this.promptId) {
12548
- this.promptId = void 0;
12549
- const events = [];
12550
- if (this.text.trim() || this.tools.length) {
12551
- events.push({
12552
- kind: "turn",
12553
- role: "agent",
12554
- text: this.text.trim(),
12555
- ...this.tools.length ? { tools: [...this.tools] } : {}
12556
- });
12557
- }
12558
- const result = this.text.trim();
12559
- const failed = msg.error !== void 0 || msg.result?.stopReason === "refusal";
12560
- this.text = "";
12561
- this.tools = [];
12562
- const writes = this.flush();
12563
- if (!writes.length) {
12564
- events.push({
12565
- kind: "idle",
12566
- ...result ? { result } : {},
12567
- ...failed ? { failed: true } : {}
12568
- });
12569
- }
12570
- return { events, writes };
12571
- }
12572
- return none;
12573
- }
12574
- // ── the agent talking to us ──
12575
- handleNotification(msg) {
12576
- if (msg.method !== "session/update") return none;
12577
- const update = msg.params?.update;
12578
- switch (update?.sessionUpdate) {
12579
- case "agent_message_chunk":
12580
- this.text += update.content?.text ?? "";
12581
- return none;
12582
- case "tool_call": {
12583
- const title = update.title?.trim() || update.kind || "tool";
12584
- this.tools.push(title);
12585
- return none;
12586
- }
12587
- default:
12588
- return none;
12589
- }
12590
- }
12591
- handleRequest(msg) {
12592
- if (msg.method !== "session/request_permission") {
12593
- return {
12594
- events: [],
12595
- writes: [frame({ id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } })]
12596
- };
12597
- }
12598
- const id = msg.id;
12599
- const options = msg.params?.options ?? [];
12600
- const title = msg.params?.toolCall?.title?.trim() || "a tool call";
12601
- const tool = msg.params?.toolCall?.kind ?? "tool";
12602
- if (this.mode === "bypass") {
12603
- const optionId = optionFor(options, { allow: true }) ?? optionFor(options, { allow: false });
12604
- return {
12605
- // The decision still goes through Paigy — as history, not a question. Bypass
12606
- // means "don't stall the agent", never "don't tell the user".
12607
- events: [{ kind: "turn", role: "agent", text: `auto-approved: ${title}`, tools: [tool] }],
12608
- writes: [
12609
- optionId ? frame({ id, result: { outcome: { outcome: "selected", optionId } } }) : frame({ id, result: { outcome: { outcome: "cancelled" } } })
12610
- ]
12611
- };
12612
- }
12613
- this.pending.set(String(id), { id, options });
12614
- return {
12615
- events: [{ kind: "permission", id: String(id), tool, summary: title }],
12616
- writes: []
12617
- };
12618
- }
12619
- /** Send the queued prompts as one turn, if the agent can take one right now. */
12620
- flush() {
12621
- if (!this.sessionId || this.promptId !== void 0 || !this.queued.length) return [];
12622
- const blocks = this.queued.map((text) => ({ type: "text", text }));
12623
- this.queued = [];
12624
- this.promptId = this.nextId++;
12625
- return [
12626
- frame({
12627
- id: this.promptId,
12628
- method: "session/prompt",
12629
- params: { sessionId: this.sessionId, prompt: blocks }
12630
- })
12631
- ];
12651
+ function resolveWakeDir(pinned, deps) {
12652
+ if (pinned && allowed(pinned, deps)) {
12653
+ const dir = resolve(pinned.replace(/^~(?=$|\/)/, homedir3()));
12654
+ if ((deps.exists ?? existsSync3)(dir)) return dir;
12632
12655
  }
12633
- };
12634
- var frame = (body) => JSON.stringify({ jsonrpc: "2.0", ...body });
12635
-
12636
- // src/harness/session.ts
12637
- var ADAPTER_BIN = {
12638
- claude: "claude-agent-acp",
12639
- codex: "codex-acp"
12640
- };
12641
- function splitLines(buffer, chunk) {
12642
- const combined = buffer + chunk;
12643
- const parts = combined.split("\n");
12644
- const rest = parts.pop() ?? "";
12645
- return { lines: parts.filter((l) => l.trim()), rest };
12646
- }
12647
- function startSession(opts) {
12648
- const cwd = resolve2(opts.cwd.replace(/^~(?=$|\/)/, homedir4()));
12649
- if (!existsSync4(cwd)) {
12650
- queueMicrotask(() => opts.onEvent({ kind: "error", message: `workspace does not exist: ${cwd}` }));
12651
- }
12652
- const child = (opts.spawnFn ?? spawn)(opts.bin ?? ADAPTER_BIN[opts.harness], [], {
12653
- cwd,
12654
- // The adapter shells out to its vendor CLI (`claude`, `codex`), and a GUI- or
12655
- // launchd-launched process's PATH won't have it — probe dirs plus the RUNNING
12656
- // node's own bin dir (nvm installs the adapters next to node; launchd's bare
12657
- // PATH knows neither — live catch 2026-08-04: the first slot-wake ENOENT'd).
12658
- env: {
12659
- ...process.env,
12660
- PATH: [process.env.PATH, ...probeDirs()].filter(Boolean).join(delimiter2),
12661
- ...opts.token ? { PAIGY_TOKEN: opts.token } : {}
12662
- },
12663
- stdio: ["pipe", "pipe", "pipe"]
12664
- });
12665
- const write = (frames) => {
12666
- for (const f of frames) child.stdin?.write(`${f}
12667
- `);
12668
- };
12669
- child.stderr?.on("data", (chunk) => {
12670
- const text = String(chunk).trim();
12671
- if (text) opts.onEvent({ kind: "error", message: text });
12672
- });
12673
- child.on("exit", (code) => {
12674
- opts.onEvent({ kind: "idle", failed: code !== 0, ...code ? { result: `exited ${code}` } : {} });
12675
- opts.onExit?.();
12676
- });
12677
- child.on("error", (e) => {
12678
- opts.onEvent({ kind: "error", message: e.message });
12679
- opts.onExit?.();
12680
- });
12681
- const driver = createAcpDriver({ cwd, mode: opts.mode, ...opts.mcp ? { mcp: opts.mcp } : {} });
12682
- let buffer = "";
12683
- child.stdout?.on("data", (chunk) => {
12684
- const { lines, rest } = splitLines(buffer, String(chunk));
12685
- buffer = rest;
12686
- for (const line of lines) {
12687
- const { events, writes } = driver.handleLine(line);
12688
- write(writes);
12689
- for (const event of events) opts.onEvent(event);
12690
- }
12691
- });
12692
- write(driver.open());
12693
- return {
12694
- send(text) {
12695
- write(driver.send(text));
12696
- },
12697
- respond(id, decision) {
12698
- write(driver.respond(id, decision));
12699
- },
12700
- stop() {
12701
- write(driver.close());
12702
- child.kill();
12703
- }
12704
- };
12656
+ return listWorkspaces(deps)[0];
12705
12657
  }
12706
12658
 
12707
12659
  // ../../packages/schema/dist/index.js
12708
12660
  var ContextSchema2 = external_exports.object({
12709
12661
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
12710
- description: external_exports.array(external_exports.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
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
+ )
12711
12665
  });
12712
12666
  var ParticipantSchema2 = external_exports.object({
12713
12667
  kind: external_exports.enum(["human", "agent"]),
@@ -12854,6 +12808,14 @@ var NotifyRequestSchema2 = external_exports.object({
12854
12808
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
12855
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."
12856
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
+ ),
12857
12819
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
12858
12820
  * choice. Outranks waiting in both directions: 'call' rings even for a
12859
12821
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -12887,7 +12849,7 @@ var NotifyRequestSchema2 = external_exports.object({
12887
12849
  if (r.ask !== void 0) {
12888
12850
  for (const f of ["context", "select", "points"]) {
12889
12851
  if (r[f] !== void 0)
12890
- 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)` });
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.` });
12891
12853
  }
12892
12854
  return;
12893
12855
  }
@@ -12903,6 +12865,23 @@ var NotifyRequestSchema2 = external_exports.object({
12903
12865
  if (!needsOptions && r.options?.length)
12904
12866
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
12905
12867
  });
12868
+ function unitsOf2(text) {
12869
+ const spans = [];
12870
+ const re = /\n\s*\n+/g;
12871
+ let cursor = 0;
12872
+ const push = (from, to) => {
12873
+ const slice = text.slice(from, to);
12874
+ const lead = slice.length - slice.trimStart().length;
12875
+ const tail = slice.length - slice.trimEnd().length;
12876
+ if (from + lead < to - tail) spans.push({ start: from + lead, end: to - tail });
12877
+ };
12878
+ for (let m = re.exec(text); m; m = re.exec(text)) {
12879
+ push(cursor, m.index);
12880
+ cursor = m.index + m[0].length;
12881
+ }
12882
+ push(cursor, text.length);
12883
+ return spans;
12884
+ }
12906
12885
  var NotifyStatusSchema2 = external_exports.enum(["pending", "answered", "ignored"]);
12907
12886
  var AgentStateSchema2 = external_exports.enum(["idle", "in_progress", "completed", "needs_input"]);
12908
12887
  var SetTaskStateSchema2 = external_exports.object({
@@ -13085,6 +13064,40 @@ var NotifyResponseSchema2 = external_exports.object({
13085
13064
  answer: UserAnswerSchema2.optional(),
13086
13065
  answeredAt: external_exports.string().datetime().optional()
13087
13066
  });
13067
+ var NotifyPlanUnitSchema2 = external_exports.object({
13068
+ notificationId: external_exports.string(),
13069
+ /** The unit's own heading, so the agent can tell which of its paragraphs this became. */
13070
+ title: external_exports.string(),
13071
+ /** How loudly this unit was arbitrated to arrive — per unit, which is the point of units. */
13072
+ level: NotifyLevelSchema2,
13073
+ /** Answered from something the user already decided: nobody is interrupted, and a trail card
13074
+ * says so. The agent should not wait on this one. */
13075
+ settled: external_exports.literal(true).optional(),
13076
+ /** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
13077
+ * agent — nothing here changed the ask, and ignoring it costs nothing. */
13078
+ needs: external_exports.array(external_exports.enum(["options", "visuals"])).optional(),
13079
+ /** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
13080
+ * split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
13081
+ * broker that DECIDES that destroys the only fact separating a statement from a real ask
13082
+ * (#731), so it is offered, never applied: the unit is stored `text` until the agent
13083
+ * confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
13084
+ proposal: external_exports.object({
13085
+ select: SelectShapeSchema2,
13086
+ options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).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()
13094
+ });
13095
+ var NotifyPlanSchema2 = external_exports.object({
13096
+ units: external_exports.array(NotifyPlanUnitSchema2),
13097
+ /** Which unit is this arrival's ONE interruption (units-design.md D23). Absent means nobody
13098
+ * was interrupted — every unit was either settled or quiet enough to sit in the inbox. */
13099
+ speaks: external_exports.string().optional()
13100
+ });
13088
13101
  var UserResponseSchema2 = external_exports.object({
13089
13102
  requestId: external_exports.string(),
13090
13103
  answer: UserAnswerSchema2,
@@ -13139,6 +13152,20 @@ var InboxItemSchema2 = external_exports.object({
13139
13152
  /** The conversation thread + connection this item lives on. Present on the replied
13140
13153
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
13141
13154
  parentId: external_exports.string().optional(),
13155
+ /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
13156
+ * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
13157
+ * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
13158
+ * accumulates every message an agent ever sent, so grouping by it renders a day of
13159
+ * unrelated updates as a single "12-part request". Absent on rows written before the
13160
+ * `asks` table, and on anything that never went through `notify` — both fall back to the
13161
+ * thread, which is what the client did for all rows until now. */
13162
+ askId: external_exports.string().optional(),
13163
+ /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
13164
+ * shares one `created_at` to the microsecond, so without it the author's order is
13165
+ * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
13166
+ * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
13167
+ * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
13168
+ seq: external_exports.number().int().optional(),
13142
13169
  tokenId: external_exports.string().optional(),
13143
13170
  status: NotifyStatusSchema2,
13144
13171
  context: ContextSchema2,
@@ -13149,6 +13176,16 @@ var InboxItemSchema2 = external_exports.object({
13149
13176
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
13150
13177
  * between them could not express a call as a sequence. `question: null` is a real turn —
13151
13178
  * a status update stays a statement instead of being shaped into a yes/no. */
13179
+ /** Does this claim want an ANSWER, or is it telling you something? Written per row from
13180
+ * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
13181
+ * its acknowledge affordance: without it a status update offers a text box and a dismiss,
13182
+ * and neither of those is "got it" (owner, 2026-08-10). */
13183
+ asks: external_exports.boolean().optional(),
13184
+ /** When a live process last pulsed for this row's agent — the liveness input for
13185
+ * "working requires a pulse" (#928): the list said "Working…" from agent_state alone
13186
+ * while the party called the same dead claim stalled. Absent = no token/no data,
13187
+ * which must never CLAIM stalled. */
13188
+ lastSeenAt: external_exports.string().optional(),
13152
13189
  agenda: external_exports.array(AgendaTurnSchema2).optional(),
13153
13190
  /** On a replied detail (#397): the next steps the user attached to the answer
13154
13191
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -13173,6 +13210,23 @@ var InboxItemSchema2 = external_exports.object({
13173
13210
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
13174
13211
  error: external_exports.string().optional(),
13175
13212
  clarifies: external_exports.string().optional(),
13213
+ /** The ring ladder ran out while this was still pending — we tried to reach you and
13214
+ * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
13215
+ * agent with nothing to say, which the roster drew identically until now: "nothing to
13216
+ * say" and "gave up saying it" are opposite situations wearing the same face
13217
+ * (navigation-design.md, gap 1). False for anything that never rang. */
13218
+ gaveUp: external_exports.boolean().default(false),
13219
+ /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
13220
+ * Absent for anything never delivered through a push, and for older rows written before
13221
+ * the reason was recorded. Deliberately a debug affordance, shown small (owner,
13222
+ * 2026-08-07) — its real job is to give "this didn't need a call" something to be
13223
+ * feedback ABOUT. */
13224
+ why: external_exports.object({
13225
+ asked: NotifyLevelSchema2,
13226
+ got: NotifyLevelSchema2,
13227
+ because: external_exports.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
13228
+ line: external_exports.string()
13229
+ }).optional(),
13176
13230
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
13177
13231
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
13178
13232
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -13283,6 +13337,15 @@ var HistoryItemSchema2 = external_exports.object({
13283
13337
  /** When you answered the agent's notification (agent→user only). */
13284
13338
  humanAckedAt: external_exports.string().nullable()
13285
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
+ });
13286
13349
  var ConnectionSummarySchema2 = external_exports.object({
13287
13350
  /** The connection = the agent's token id (used to address a request). */
13288
13351
  id: external_exports.string(),
@@ -13294,6 +13357,14 @@ var ConnectionSummarySchema2 = external_exports.object({
13294
13357
  provider: external_exports.string().nullable(),
13295
13358
  /** The pairing's assigned voice (#462); null = the default voice. */
13296
13359
  voice: VoiceKeySchema2.nullable(),
13360
+ /** The LOUDEST this agent may ever reach you — a ceiling on `NOTIFY_LADDER`, set by the
13361
+ * user on the agent's own page. null = no ceiling (today's behaviour for every
13362
+ * connection). Android binds importance to a relationship rather than to each message,
13363
+ * and that is the thing our roster could not say: "Marlow may always call me; Otto
13364
+ * never may" (navigation-design.md, gap 2). Clamped in `arbitrateLevel`, so it binds
13365
+ * every surface at once and outranks even `sessionMode: all_calls` — a mode the user
13366
+ * set once must not overrule a rule they set about one agent. */
13367
+ reach: NotifyLevelSchema2.nullable().optional(),
13297
13368
  createdAt: external_exports.string().datetime(),
13298
13369
  /** Most recent notification on this connection, either direction. Null = no contact yet.
13299
13370
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -13308,10 +13379,49 @@ var ConnectionSummarySchema2 = external_exports.object({
13308
13379
  harnesses: external_exports.array(external_exports.object({ name: external_exports.string(), label: external_exports.string(), status: external_exports.string() })).optional(),
13309
13380
  workspaces: external_exports.array(external_exports.string()).optional()
13310
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(),
13311
13387
  /** True = a provider-managed agent running in the provider's cloud (e.g. Anthropic CMA);
13312
13388
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
13313
13389
  managed: external_exports.boolean()
13314
13390
  });
13391
+ var MoveRingSchema2 = external_exports.enum(["home", "travels", "retired", "quarantined"]);
13392
+ var MoveSchema2 = external_exports.object({
13393
+ id: external_exports.string(),
13394
+ /** The reusable question, as distill normalized it. */
13395
+ question: external_exports.string(),
13396
+ /** The operative ruling. Editable by the user (PATCH) — which resets the ledger. */
13397
+ answer: external_exports.string(),
13398
+ /** The user's stated reason, when they gave one. Null = inherently narrow: the judge is
13399
+ * told so, and the ruling only derives essentially the same question in the same scope. */
13400
+ rationale: external_exports.string().nullable(),
13401
+ /** Where the ruling lives: a repo/workspace, or 'global'. */
13402
+ scope: external_exports.string(),
13403
+ ring: MoveRingSchema2,
13404
+ /** True = the user pinned it with `always` (travel granted by hand, not by evidence). */
13405
+ pinned: external_exports.boolean(),
13406
+ /** True = a pin the user placed was BROKEN by later counter-evidence. Surfaced so the
13407
+ * break is visible instead of a pin silently disappearing. */
13408
+ pinBroken: external_exports.boolean(),
13409
+ /** When the ruling was distilled. */
13410
+ learnedAt: external_exports.string(),
13411
+ /** Last time it answered an ask. Null = never fired. */
13412
+ lastUsedAt: external_exports.string().nullable(),
13413
+ /** How many asks it has answered. Instrumentation — deliberately NOT an input to the
13414
+ * evidence curve: firing says the question keeps arising, not that the ruling is right. */
13415
+ usedCount: external_exports.number(),
13416
+ /** Ledger: outcomes that said it held up. Saturating — the tenth is worth almost nothing. */
13417
+ confirms: external_exports.number(),
13418
+ /** Ledger: contradictions, in signal units (a full override = 1, weaker signals less).
13419
+ * Linear and priced above the entire confirmation budget, so any full counter wins. */
13420
+ counters: external_exports.number(),
13421
+ /** The agent that asked the question this move came from, when known. Null for a move
13422
+ * distilled from a clarify ruling (those carry no agent) or one whose source rows are gone. */
13423
+ learnedFrom: external_exports.object({ id: external_exports.string(), name: external_exports.string() }).nullable()
13424
+ });
13315
13425
  var CreateRequestSchema2 = external_exports.object({
13316
13426
  /** The connection (token id) to send to, from GET /api/tokens. */
13317
13427
  tokenId: external_exports.string(),
@@ -13518,6 +13628,17 @@ var DeviceTokenSchema2 = external_exports.object({
13518
13628
  * a default silly name). */
13519
13629
  name: external_exports.string(),
13520
13630
  device: external_exports.string().nullable(),
13631
+ /** The pairing's assigned voice, cached so the desktop can seed the SAME face the phone
13632
+ * draws — voice is the third ingredient of a hatchling's build (party/traits.ts). */
13633
+ voice: external_exports.string().nullable().optional(),
13634
+ /** The token's server-side id — the face's COLOUR anchor, and the only seed ingredient
13635
+ * that survives a rename. Cached by the host's identity beat. */
13636
+ token_id: external_exports.string().nullable().optional(),
13637
+ /** WHERE this identity works — the folder a wake should land it in. Written by the host
13638
+ * at spawn and by `paigy-harness handoff` from a live terminal. Without it every wake
13639
+ * landed in the FIRST granted workspace and the agent rediscovered its own repo from
13640
+ * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
13641
+ workspace: external_exports.string().nullable().optional(),
13521
13642
  phone_reveal: PairingRevealSchema2.nullable().optional(),
13522
13643
  // present once the phone reveals
13523
13644
  uik_pub: external_exports.string().nullable().optional()
@@ -13544,6 +13665,8 @@ var NotificationFeedbackKindSchema2 = external_exports.enum([
13544
13665
  // "There should be a picture or design here."
13545
13666
  "should_have_called",
13546
13667
  // "Don't put this in a banner — ring me for something like this."
13668
+ "should_have_messaged",
13669
+ // the inverse: "that didn't deserve a ring — a message would do."
13547
13670
  "other"
13548
13671
  // anything else — the note carries it.
13549
13672
  ]);
@@ -13564,6 +13687,323 @@ var FeedbackOutcomeSchema2 = external_exports.object({
13564
13687
  childIds: external_exports.array(external_exports.string()).optional()
13565
13688
  });
13566
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
+
13567
14007
  // src/paigy/conversation.ts
13568
14008
  function endsWithQuestion(text) {
13569
14009
  if (!text) return false;
@@ -13658,6 +14098,9 @@ function levelFor(event) {
13658
14098
  case "idle":
13659
14099
  if (endsWithQuestion(event.result)) return "banner";
13660
14100
  return event.failed ? "push" : "inbox";
14101
+ case "work":
14102
+ return "inbox";
14103
+ // moot — entryFor drops work before a level ever matters
13661
14104
  case "error":
13662
14105
  return "inbox";
13663
14106
  }
@@ -13678,16 +14121,14 @@ function entryFor(event, state = {}) {
13678
14121
  switch (event.kind) {
13679
14122
  case "turn": {
13680
14123
  const who = event.role === "agent" ? "Agent" : "You";
13681
- const title = event.text ? `${who}: ${firstLine(event.text)}` : `${who} ran ${(event.tools ?? []).join(", ")}`;
13682
- const description = [event.text, event.tools?.length ? `Tools: ${event.tools.join(", ")}` : null].filter((c) => Boolean(c));
14124
+ const body = event.text ? event.role === "agent" ? event.text : `${who}: ${event.text}` : `${who} ran ${(event.tools ?? []).join(", ")}.`;
13683
14125
  return {
13684
14126
  ...common,
13685
- context: { title: clip(title), description: description.length ? description : [title] },
13686
- // The shaped form always carries an answer shape the contract's rule, and the
13687
- // right one: any inbox row can be replied to, and a reply to history is just
13688
- // the user initiating (the pump feeds it back in). Non-blocking, so it never
13689
- // reads as a question.
13690
- select: "text"
14127
+ ask: body
14128
+ // No `select`: the contract refuses it on the `ask` form ("the broker derives it"),
14129
+ // and with no options it derives "text" anyway the shape this wants, since any
14130
+ // inbox row can be replied to and a reply to history is just the user initiating
14131
+ // (the pump feeds it back in).
13691
14132
  };
13692
14133
  }
13693
14134
  case "permission":
@@ -13709,16 +14150,24 @@ function entryFor(event, state = {}) {
13709
14150
  };
13710
14151
  case "idle": {
13711
14152
  const asking = endsWithQuestion(event.result);
14153
+ const result = event.result?.trim();
14154
+ const norm = (t) => (t ?? "").replace(/\s+/g, " ").trim();
14155
+ if (result && !event.failed && norm(result) === norm(state.lastAgentText)) {
14156
+ if (!asking) return null;
14157
+ const spans = unitsOf2(result);
14158
+ const tail = spans.map((sp) => result.slice(sp.start, sp.end)).reverse().find((t) => t.includes("?"));
14159
+ return { ...common, ask: tail ?? result, blocking: true };
14160
+ }
14161
+ const lead = asking ? "" : event.failed ? "The agent stopped without finishing. " : "Turn complete. ";
13712
14162
  return {
13713
14163
  ...common,
13714
- context: {
13715
- title: asking ? clip(firstLine(event.result ?? "")) : event.failed ? "The agent stopped without finishing" : "Turn complete",
13716
- description: [event.result || (event.failed ? "No result reported." : "Done.")]
13717
- },
13718
- select: "text",
14164
+ ask: `${lead}${result || (event.failed ? "No result reported." : "Done.")}`,
13719
14165
  ...asking ? { blocking: true } : {}
13720
14166
  };
13721
14167
  }
14168
+ case "work":
14169
+ return null;
14170
+ // log-only by contract — the live texture of the working log
13722
14171
  case "error":
13723
14172
  return null;
13724
14173
  }
@@ -13740,9 +14189,16 @@ function decisionFrom(answer) {
13740
14189
  }
13741
14190
  async function mirror(event, state, deps = {}) {
13742
14191
  const entry = entryFor(event, state);
14192
+ if (event.kind === "turn" && event.role === "agent" && event.text) state.lastAgentText = event.text;
13743
14193
  if (!entry) return {};
14194
+ let req;
14195
+ try {
14196
+ req = NotifyRequestSchema2.parse(entry);
14197
+ } catch (e) {
14198
+ console.error(`paigy: mirror entry failed the contract \u2014 a bridge bug, not the network: ${e instanceof Error ? e.message.slice(0, 300) : String(e)}`);
14199
+ return {};
14200
+ }
13744
14201
  try {
13745
- const req = NotifyRequestSchema2.parse(entry);
13746
14202
  const { notificationId, parentId } = await (deps.submit ?? submitNotification)(req);
13747
14203
  (state.mine ??= /* @__PURE__ */ new Set()).add(notificationId);
13748
14204
  return { parentId, notificationId };
@@ -13771,8 +14227,9 @@ async function askQuestion(question, state, deps = {}) {
13771
14227
  ...state.parentId ? { parentId: state.parentId } : {},
13772
14228
  ...state.repo ? { repo: state.repo } : {},
13773
14229
  ...state.branch ? { branch: state.branch } : {},
13774
- context: { title: clip(firstLine(question)), description: [question] },
13775
- select: "text",
14230
+ // Prose in see `entryFor`'s `turn` case. An agent's question is the case most likely
14231
+ // to run long, and its title was a clipped prefix of itself.
14232
+ ask: question,
13776
14233
  blocking: true,
13777
14234
  urgency: "banner"
13778
14235
  };
@@ -13807,12 +14264,12 @@ function spokenText(answer) {
13807
14264
  return null;
13808
14265
  }
13809
14266
  }
13810
- var firstLine = (s) => s.split("\n")[0] ?? s;
13811
14267
  var clip = (s) => (s.length > 120 ? `${s.slice(0, 117)}\u2026` : s) || "(no text)";
13812
14268
 
13813
14269
  // src/run.ts
13814
14270
  function runHarness(opts) {
13815
14271
  let running = true;
14272
+ let tail = [];
13816
14273
  const state = { ...opts.exclusive ? { exclusive: true } : {} };
13817
14274
  let session = null;
13818
14275
  const asMe = { token: opts.token };
@@ -13840,11 +14297,17 @@ function runHarness(opts) {
13840
14297
  opts.log(decision.allow ? `\u2713 approved: ${event.summary}` : `\u2717 denied: ${event.summary}`);
13841
14298
  return;
13842
14299
  }
14300
+ if (event.kind === "work") {
14301
+ opts.log(`\u2699 ${event.tool}${event.note ? ` \u2014 ${event.note.split("\n")[0] ?? ""}` : ""}`);
14302
+ tail = pushWork(tail, workLine(event));
14303
+ return;
14304
+ }
13843
14305
  const { parentId } = await mirror(event, state, deps);
13844
14306
  state.parentId ??= parentId;
13845
14307
  if (event.kind === "turn") opts.log(`${event.role}: ${event.text.split("\n")[0] ?? ""}`);
13846
14308
  if (event.kind === "idle") {
13847
14309
  state.resting = true;
14310
+ tail = [];
13848
14311
  if (endsWithQuestion(event.result)) {
13849
14312
  const local = await opts.localAsk?.question?.(event.result ?? "") ?? null;
13850
14313
  if (local?.trim() && session && running) {
@@ -13882,8 +14345,10 @@ function runHarness(opts) {
13882
14345
  session?.send(text);
13883
14346
  },
13884
14347
  working: () => running && state.resting !== true,
14348
+ tail: () => [...tail],
13885
14349
  stop() {
13886
14350
  running = false;
14351
+ tail = [];
13887
14352
  cancelAsks(state);
13888
14353
  session?.stop();
13889
14354
  session = null;
@@ -13915,16 +14380,22 @@ function hostElsewhere() {
13915
14380
  function startHost(opts) {
13916
14381
  const runs = /* @__PURE__ */ new Map();
13917
14382
  const asHost = { token: opts.token };
13918
- const refreshNames = async () => {
14383
+ const refreshIdentities = async () => {
13919
14384
  for (const slot of listSlots()) {
13920
14385
  const token = readToken(slot);
13921
14386
  if (!token) continue;
13922
14387
  const me = await whoAmI({ token }).catch(() => null);
13923
- if (me?.name && me.name !== slotName(slot)) saveToken({ access_token: token, name: me.name, device: null }, slot);
14388
+ if (!me) continue;
14389
+ const have = slotIdentity(slot);
14390
+ if (me.name !== have.name || (me.voice ?? null) !== have.voice || (me.tokenId ?? null) !== have.tokenId) {
14391
+ updateSlot(slot, { ...me.name ? { name: me.name } : {}, voice: me.voice ?? null, token_id: me.tokenId ?? null });
14392
+ }
13924
14393
  }
13925
14394
  };
13926
14395
  const beat = () => {
13927
- void refreshNames();
14396
+ void refreshIdentities();
14397
+ for (const r of runs.values()) if (r.token) void heartbeat(void 0, { token: r.token }).catch(() => {
14398
+ });
13928
14399
  void heartbeat({
13929
14400
  harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status })),
13930
14401
  workspaces: listWorkspaces(opts.wsDeps)
@@ -13949,25 +14420,28 @@ function startHost(opts) {
13949
14420
  },
13950
14421
  log: log2
13951
14422
  });
13952
- runs.set(spec.sessionId, { run, label });
14423
+ runs.set(spec.sessionId, { run, label, token: spec.token });
14424
+ void heartbeat(void 0, { token: spec.token }).catch(() => {
14425
+ });
14426
+ void refreshIdentities();
13953
14427
  try {
13954
- saveToken({ access_token: spec.token, name: label, device: null }, `session:${spec.sessionId.slice(0, 8)}`);
14428
+ saveToken({ access_token: spec.token, name: label, device: null, workspace: spec.workspace }, sessionSlot(spec.sessionId));
13955
14429
  } catch {
13956
14430
  }
13957
14431
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
13958
14432
  }
13959
14433
  }
13960
- const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex" };
13961
- 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.";
14434
+ const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex", antigravity: "agy" };
14435
+ 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.";
13962
14436
  async function sweepSlots() {
13963
- const workspace = listWorkspaces(opts.wsDeps)[0];
13964
- if (!workspace) return;
13965
14437
  for (const slot of listSlots()) {
13966
14438
  const harness = SLOT_HARNESS[slot] ?? (slot === "Desktop" ? void 0 : "claude");
13967
14439
  const key = `slot:${slot}`;
13968
14440
  if (!harness || runs.has(key)) continue;
13969
14441
  const token = readToken(slot);
13970
14442
  if (!token) continue;
14443
+ const workspace = resolveWakeDir(slotIdentity(slot).workspace, opts.wsDeps);
14444
+ if (!workspace) continue;
13971
14445
  const work = await checkReplies({ token }).catch(() => null);
13972
14446
  if (!work || work.requests.length === 0 && work.replies.length === 0) continue;
13973
14447
  const label = slotName(slot) ?? slot;
@@ -13986,10 +14460,33 @@ function startHost(opts) {
13986
14460
  },
13987
14461
  log: log2
13988
14462
  });
13989
- runs.set(key, { run, label });
14463
+ runs.set(key, { run, label, token });
14464
+ void heartbeat(void 0, { token }).catch(() => {
14465
+ });
13990
14466
  opts.log(`\u25B6 woke ${label} (${harness}) \u2014 work was waiting in ${workspace}`);
13991
14467
  }
13992
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
+ };
13993
14490
  const publish = () => {
13994
14491
  try {
13995
14492
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: Date.now(), roster: api.roster() }));
@@ -14003,6 +14500,7 @@ function startHost(opts) {
14003
14500
  void sweepSlots();
14004
14501
  publish();
14005
14502
  }, 5e3);
14503
+ const activityTick = setInterval(streamActivity, ACTIVITY_MS);
14006
14504
  const stopSessions = () => {
14007
14505
  for (const { run, label } of runs.values()) {
14008
14506
  run.stop();
@@ -14019,18 +14517,29 @@ function startHost(opts) {
14019
14517
  const live = new Map([...runs.values()].map((r) => [r.label, r.run]));
14020
14518
  const names = /* @__PURE__ */ new Map();
14021
14519
  for (const key of listSlots()) if (key !== "Desktop") names.set(slotName(key) ?? key, key);
14022
- for (const label of live.keys()) if (!names.has(label)) names.set(label, label);
14023
- return [...names.keys()].map((name) => ({
14024
- name,
14025
- running: live.has(name),
14026
- working: live.get(name)?.working() ?? false
14027
- }));
14520
+ for (const label of live.keys()) if (!names.has(label)) names.set(label, null);
14521
+ return [...names.entries()].map(([name, slot]) => {
14522
+ const id = slot ? slotIdentity(slot) : { voice: null, tokenId: null };
14523
+ return {
14524
+ name,
14525
+ // The slot key rides along so the window's agent page can speak AS this agent —
14526
+ // its pending reads (`checkReplies`, the same pure read the sweep does) need the
14527
+ // slot's own token, and the display name is not a key.
14528
+ slot,
14529
+ tokenId: id.tokenId,
14530
+ voice: id.voice,
14531
+ running: live.has(name),
14532
+ working: live.get(name)?.working() ?? false
14533
+ };
14534
+ });
14028
14535
  },
14029
14536
  stopSessions,
14030
14537
  stop() {
14031
14538
  clearInterval(pulse);
14032
14539
  clearInterval(spawnPoll);
14033
14540
  stopSessions();
14541
+ streamActivity();
14542
+ clearInterval(activityTick);
14034
14543
  try {
14035
14544
  writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: 0, roster: [] }));
14036
14545
  } catch {
@@ -14045,7 +14554,7 @@ function startHost(opts) {
14045
14554
  var here = dirname4(fileURLToPath(import.meta.url));
14046
14555
  var wsDeps = { file: workspacesFile() };
14047
14556
  var DEVICE_SLOT = "Desktop";
14048
- var deviceToken = () => readToken(DEVICE_SLOT) || readToken();
14557
+ var deviceToken = () => readToken(DEVICE_SLOT);
14049
14558
  var win = null;
14050
14559
  function log(message) {
14051
14560
  win?.webContents.send("paigy:log", message);
@@ -14053,8 +14562,13 @@ function log(message) {
14053
14562
  var ICON = join5(here, "..", "assets", "icon.png");
14054
14563
  function createWindow() {
14055
14564
  win = new BrowserWindow({
14056
- width: 720,
14057
- height: 640,
14565
+ // Sized for the working log, which is now the window's centerpiece; the layout is a
14566
+ // single fluid column (max-width 1080, logs wrap at any token), so any size between
14567
+ // the min and a full screen holds without a horizontal scrollbar.
14568
+ width: 880,
14569
+ height: 820,
14570
+ minWidth: 560,
14571
+ minHeight: 520,
14058
14572
  icon: ICON,
14059
14573
  // Windows/Linux window icon; macOS uses the dock icon below
14060
14574
  webPreferences: { preload: join5(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
@@ -14112,6 +14626,31 @@ ipcMain.handle("paigy:beacon", async () => {
14112
14626
  })();
14113
14627
  return { qr, code: code.user_code };
14114
14628
  });
14629
+ ipcMain.handle("paigy:agent", async (_e, name) => {
14630
+ const entry = (host?.roster() ?? readHostState()?.roster ?? []).find((a) => a.name === name);
14631
+ if (!entry) return null;
14632
+ const token = entry.slot ? readToken(entry.slot) : "";
14633
+ const pending = token ? await checkReplies({ token }).catch(() => null) : null;
14634
+ return {
14635
+ ...entry,
14636
+ pending: pending ? {
14637
+ requests: pending.requests.slice(0, 5).map((r) => ({
14638
+ line: (r.text.split("\n").find(Boolean) ?? "").slice(0, 140),
14639
+ at: r.createdAt
14640
+ })),
14641
+ replies: pending.replies.length
14642
+ } : null
14643
+ };
14644
+ });
14645
+ ipcMain.handle("paigy:agent-log", (_e, name) => {
14646
+ try {
14647
+ const all = readFileSync4(join5(homedir6(), ".paigy", "host.log"), "utf8").split("\n");
14648
+ const mine = all.filter((l) => l.includes(`[${name}]`) || /[▶✖◉⚠]/.test(l) && l.includes(name));
14649
+ return mine.slice(-500).join("\n");
14650
+ } catch {
14651
+ return "";
14652
+ }
14653
+ });
14115
14654
  ipcMain.handle("paigy:workspaces", () => listWorkspaces(wsDeps));
14116
14655
  ipcMain.handle("paigy:workspace-add", async () => {
14117
14656
  const picked = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });