@paigy/harness 0.2.12 → 0.3.0

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 +518 -71
  3. package/dist/main.js +514 -68
  4. package/package.json +4 -3
package/dist/cli.js CHANGED
@@ -23112,6 +23112,14 @@ var CATALOG = [
23112
23112
  loginHint: "Run `codex login` to authenticate.",
23113
23113
  installCli: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
23114
23114
  installAdapter: "npm install -g @agentclientprotocol/codex-acp"
23115
+ },
23116
+ {
23117
+ name: "agy",
23118
+ label: "Antigravity",
23119
+ cli: "agy",
23120
+ authProbe: ["agy", "help"],
23121
+ loginHint: "Install and set up Antigravity (`agy`).",
23122
+ installCli: "curl -fsSL https://antigravity.dev/install.sh | bash"
23115
23123
  }
23116
23124
  ];
23117
23125
  function probeDirs(home = homedir()) {
@@ -23169,6 +23177,7 @@ function detectAll(deps = {}) {
23169
23177
 
23170
23178
  // ../../packages/sdk/dist/index.js
23171
23179
  import { createRequire as __sdkCreateRequire } from "module";
23180
+ import { randomUUID } from "crypto";
23172
23181
 
23173
23182
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
23174
23183
  var external_exports = {};
@@ -27212,7 +27221,8 @@ var coerce = {
27212
27221
  var NEVER = INVALID;
27213
27222
 
27214
27223
  // ../../packages/sdk/dist/index.js
27215
- import { existsSync as existsSync2, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
27224
+ import { closeSync, existsSync as existsSync2, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
27225
+ import { randomUUID as randomUUID2 } from "crypto";
27216
27226
  import { homedir as homedir2 } from "os";
27217
27227
  import { join as join2 } from "path";
27218
27228
  var require2 = __sdkCreateRequire(import.meta.url);
@@ -29478,16 +29488,25 @@ async function proxy() {
29478
29488
  if (!PROXY_ENV.some((k) => process.env[k])) return void 0;
29479
29489
  return agent ??= new (await Promise.resolve().then(() => __toESM(require_undici(), 1))).EnvHttpProxyAgent();
29480
29490
  }
29491
+ var INSTANCE_ID = randomUUID();
29492
+ var SESSION_ID = process.env.PAIGY_SESSION_ID ?? process.env.CLAUDE_CODE_SESSION_ID ?? INSTANCE_ID;
29481
29493
  async function reach(url, init) {
29482
29494
  try {
29483
- return await fetch(url, { ...init, dispatcher: await proxy() });
29495
+ const headers = {
29496
+ ...init?.headers,
29497
+ "x-paigy-instance": INSTANCE_ID,
29498
+ "x-paigy-session": SESSION_ID
29499
+ };
29500
+ return await fetch(url, { ...init, headers, dispatcher: await proxy() });
29484
29501
  } catch (e) {
29485
29502
  throw new Error(`${NETWORK_MSG} (${e?.message ?? String(e)})`);
29486
29503
  }
29487
29504
  }
29488
29505
  var ContextSchema = external_exports.object({
29489
29506
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
29490
- 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.")
29507
+ description: external_exports.array(external_exports.string().min(1)).describe(
29508
+ "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."
29509
+ )
29491
29510
  });
29492
29511
  var ParticipantSchema = external_exports.object({
29493
29512
  kind: external_exports.enum(["human", "agent"]),
@@ -29634,6 +29653,14 @@ var NotifyRequestSchema = external_exports.object({
29634
29653
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
29635
29654
  "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."
29636
29655
  ),
29656
+ /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
29657
+ * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
29658
+ * holding by default would charge every quiet claim that minute before any agent could
29659
+ * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
29660
+ * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
29661
+ confirm: external_exports.boolean().optional().describe(
29662
+ "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'."
29663
+ ),
29637
29664
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
29638
29665
  * choice. Outranks waiting in both directions: 'call' rings even for a
29639
29666
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -29692,35 +29719,45 @@ function normalizeWaiting(req) {
29692
29719
  blocking: req.blocking || waiting === "hard"
29693
29720
  };
29694
29721
  }
29695
- var DERIVE_CHUNKS_MAX = 8;
29696
- var DERIVE_CHUNK_MAX = 300;
29697
- function chunkAsk(text, max = DERIVE_CHUNK_MAX, cap = DERIVE_CHUNKS_MAX) {
29722
+ function unitsOf(text) {
29723
+ const spans = [];
29724
+ const re = /\n\s*\n+/g;
29725
+ let cursor = 0;
29726
+ const push = (from, to) => {
29727
+ const slice = text.slice(from, to);
29728
+ const lead = slice.length - slice.trimStart().length;
29729
+ const tail = slice.length - slice.trimEnd().length;
29730
+ if (from + lead < to - tail) spans.push({ start: from + lead, end: to - tail });
29731
+ };
29732
+ for (let m = re.exec(text); m; m = re.exec(text)) {
29733
+ push(cursor, m.index);
29734
+ cursor = m.index + m[0].length;
29735
+ }
29736
+ push(cursor, text.length);
29737
+ return spans;
29738
+ }
29739
+ function headline(text) {
29740
+ 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) ?? "";
29741
+ return (/^[^.!?\n]+[.!?]?/.exec(line)?.[0] ?? line).trim();
29742
+ }
29743
+ function bodyAfterHeadline(text) {
29698
29744
  const body = text.trim();
29699
- if (!body) return [];
29700
- const sentences = body.match(/[^.!?]+[.!?]*\s*/g) ?? [body];
29701
- const chunks = [];
29702
- for (const raw of sentences) {
29703
- const s = raw.trim();
29704
- if (!s) continue;
29705
- const last = chunks[chunks.length - 1];
29706
- if (last !== void 0 && `${last} ${s}`.length <= max) chunks[chunks.length - 1] = `${last} ${s}`;
29707
- else chunks.push(s);
29708
- }
29709
- if (chunks.length <= cap) return chunks;
29710
- return [...chunks.slice(0, cap - 1), chunks.slice(cap - 1).join(" ")];
29745
+ const title = headline(body);
29746
+ const rest = body.slice(title.length).replace(/^[\s.!?—–-]+/, "").trim();
29747
+ if (!rest) return { title, description: [] };
29748
+ const chunks = unitsOf(rest).map((u) => rest.slice(u.start, u.end)).filter(Boolean);
29749
+ return { title, description: chunks.length ? chunks : [rest] };
29711
29750
  }
29712
29751
  function deriveAsk(req) {
29713
29752
  req = normalizeWaiting(req);
29714
29753
  if (!req.ask) return req;
29715
29754
  const text = req.ask.trim();
29716
- const firstSentence = (/^[^.!?\n]+[.!?]?/.exec(text)?.[0] ?? text).trim();
29717
- const title = firstSentence.length > 90 ? `${firstSentence.slice(0, 87).trimEnd()}\u2026` : firstSentence;
29718
29755
  const hinted = req.urgencyHint === "now" ? "call" : req.urgencyHint === "soon" ? "banner" : req.urgencyHint === "whenever" ? "inbox" : req.urgency;
29719
29756
  const urgency = req.channel === "call" ? "call" : req.channel === "message" && hinted === "call" ? "banner" : hinted;
29720
29757
  const { ask: _ask, needs, urgencyHint: _hint, channel: _channel, ...rest } = req;
29721
29758
  return {
29722
29759
  ...rest,
29723
- context: { title, description: chunkAsk(text) },
29760
+ context: bodyAfterHeadline(text),
29724
29761
  // Options riding alongside the ask (#575: pixels can't be prose) floor to a
29725
29762
  // single pick — the model broker may upgrade to many/rank from the wording.
29726
29763
  select: req.options?.length ? "one" : "text",
@@ -29910,6 +29947,34 @@ var NotifyResponseSchema = external_exports.object({
29910
29947
  answer: UserAnswerSchema.optional(),
29911
29948
  answeredAt: external_exports.string().datetime().optional()
29912
29949
  });
29950
+ var NotifyPlanUnitSchema = external_exports.object({
29951
+ notificationId: external_exports.string(),
29952
+ /** The unit's own heading, so the agent can tell which of its paragraphs this became. */
29953
+ title: external_exports.string(),
29954
+ /** How loudly this unit was arbitrated to arrive — per unit, which is the point of units. */
29955
+ level: NotifyLevelSchema,
29956
+ /** Answered from something the user already decided: nobody is interrupted, and a trail card
29957
+ * says so. The agent should not wait on this one. */
29958
+ settled: external_exports.literal(true).optional(),
29959
+ /** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
29960
+ * agent — nothing here changed the ask, and ignoring it costs nothing. */
29961
+ needs: external_exports.array(external_exports.enum(["options", "visuals"])).optional(),
29962
+ /** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
29963
+ * split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
29964
+ * broker that DECIDES that destroys the only fact separating a statement from a real ask
29965
+ * (#731), so it is offered, never applied: the unit is stored `text` until the agent
29966
+ * confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
29967
+ proposal: external_exports.object({
29968
+ select: SelectShapeSchema,
29969
+ options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
29970
+ }).optional()
29971
+ });
29972
+ var NotifyPlanSchema = external_exports.object({
29973
+ units: external_exports.array(NotifyPlanUnitSchema),
29974
+ /** Which unit is this arrival's ONE interruption (units-design.md D23). Absent means nobody
29975
+ * was interrupted — every unit was either settled or quiet enough to sit in the inbox. */
29976
+ speaks: external_exports.string().optional()
29977
+ });
29913
29978
  var UserResponseSchema = external_exports.object({
29914
29979
  requestId: external_exports.string(),
29915
29980
  answer: UserAnswerSchema,
@@ -29964,6 +30029,20 @@ var InboxItemSchema = external_exports.object({
29964
30029
  /** The conversation thread + connection this item lives on. Present on the replied
29965
30030
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
29966
30031
  parentId: external_exports.string().optional(),
30032
+ /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
30033
+ * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
30034
+ * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
30035
+ * accumulates every message an agent ever sent, so grouping by it renders a day of
30036
+ * unrelated updates as a single "12-part request". Absent on rows written before the
30037
+ * `asks` table, and on anything that never went through `notify` — both fall back to the
30038
+ * thread, which is what the client did for all rows until now. */
30039
+ askId: external_exports.string().optional(),
30040
+ /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
30041
+ * shares one `created_at` to the microsecond, so without it the author's order is
30042
+ * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
30043
+ * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
30044
+ * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
30045
+ seq: external_exports.number().int().optional(),
29967
30046
  tokenId: external_exports.string().optional(),
29968
30047
  status: NotifyStatusSchema,
29969
30048
  context: ContextSchema,
@@ -29974,6 +30053,16 @@ var InboxItemSchema = external_exports.object({
29974
30053
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
29975
30054
  * between them could not express a call as a sequence. `question: null` is a real turn —
29976
30055
  * a status update stays a statement instead of being shaped into a yes/no. */
30056
+ /** Does this claim want an ANSWER, or is it telling you something? Written per row from
30057
+ * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
30058
+ * its acknowledge affordance: without it a status update offers a text box and a dismiss,
30059
+ * and neither of those is "got it" (owner, 2026-08-10). */
30060
+ asks: external_exports.boolean().optional(),
30061
+ /** When a live process last pulsed for this row's agent — the liveness input for
30062
+ * "working requires a pulse" (#928): the list said "Working…" from agent_state alone
30063
+ * while the party called the same dead claim stalled. Absent = no token/no data,
30064
+ * which must never CLAIM stalled. */
30065
+ lastSeenAt: external_exports.string().optional(),
29977
30066
  agenda: external_exports.array(AgendaTurnSchema).optional(),
29978
30067
  /** On a replied detail (#397): the next steps the user attached to the answer
29979
30068
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -29998,6 +30087,23 @@ var InboxItemSchema = external_exports.object({
29998
30087
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
29999
30088
  error: external_exports.string().optional(),
30000
30089
  clarifies: external_exports.string().optional(),
30090
+ /** The ring ladder ran out while this was still pending — we tried to reach you and
30091
+ * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
30092
+ * agent with nothing to say, which the roster drew identically until now: "nothing to
30093
+ * say" and "gave up saying it" are opposite situations wearing the same face
30094
+ * (navigation-design.md, gap 1). False for anything that never rang. */
30095
+ gaveUp: external_exports.boolean().default(false),
30096
+ /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
30097
+ * Absent for anything never delivered through a push, and for older rows written before
30098
+ * the reason was recorded. Deliberately a debug affordance, shown small (owner,
30099
+ * 2026-08-07) — its real job is to give "this didn't need a call" something to be
30100
+ * feedback ABOUT. */
30101
+ why: external_exports.object({
30102
+ asked: NotifyLevelSchema,
30103
+ got: NotifyLevelSchema,
30104
+ because: external_exports.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
30105
+ line: external_exports.string()
30106
+ }).optional(),
30001
30107
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
30002
30108
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
30003
30109
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -30119,6 +30225,14 @@ var ConnectionSummarySchema = external_exports.object({
30119
30225
  provider: external_exports.string().nullable(),
30120
30226
  /** The pairing's assigned voice (#462); null = the default voice. */
30121
30227
  voice: VoiceKeySchema.nullable(),
30228
+ /** The LOUDEST this agent may ever reach you — a ceiling on `NOTIFY_LADDER`, set by the
30229
+ * user on the agent's own page. null = no ceiling (today's behaviour for every
30230
+ * connection). Android binds importance to a relationship rather than to each message,
30231
+ * and that is the thing our roster could not say: "Marlow may always call me; Otto
30232
+ * never may" (navigation-design.md, gap 2). Clamped in `arbitrateLevel`, so it binds
30233
+ * every surface at once and outranks even `sessionMode: all_calls` — a mode the user
30234
+ * set once must not overrule a rule they set about one agent. */
30235
+ reach: NotifyLevelSchema.nullable().optional(),
30122
30236
  createdAt: external_exports.string().datetime(),
30123
30237
  /** Most recent notification on this connection, either direction. Null = no contact yet.
30124
30238
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -30137,6 +30251,40 @@ var ConnectionSummarySchema = external_exports.object({
30137
30251
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
30138
30252
  managed: external_exports.boolean()
30139
30253
  });
30254
+ var MoveRingSchema = external_exports.enum(["home", "travels", "retired", "quarantined"]);
30255
+ var MoveSchema = external_exports.object({
30256
+ id: external_exports.string(),
30257
+ /** The reusable question, as distill normalized it. */
30258
+ question: external_exports.string(),
30259
+ /** The operative ruling. Editable by the user (PATCH) — which resets the ledger. */
30260
+ answer: external_exports.string(),
30261
+ /** The user's stated reason, when they gave one. Null = inherently narrow: the judge is
30262
+ * told so, and the ruling only derives essentially the same question in the same scope. */
30263
+ rationale: external_exports.string().nullable(),
30264
+ /** Where the ruling lives: a repo/workspace, or 'global'. */
30265
+ scope: external_exports.string(),
30266
+ ring: MoveRingSchema,
30267
+ /** True = the user pinned it with `always` (travel granted by hand, not by evidence). */
30268
+ pinned: external_exports.boolean(),
30269
+ /** True = a pin the user placed was BROKEN by later counter-evidence. Surfaced so the
30270
+ * break is visible instead of a pin silently disappearing. */
30271
+ pinBroken: external_exports.boolean(),
30272
+ /** When the ruling was distilled. */
30273
+ learnedAt: external_exports.string(),
30274
+ /** Last time it answered an ask. Null = never fired. */
30275
+ lastUsedAt: external_exports.string().nullable(),
30276
+ /** How many asks it has answered. Instrumentation — deliberately NOT an input to the
30277
+ * evidence curve: firing says the question keeps arising, not that the ruling is right. */
30278
+ usedCount: external_exports.number(),
30279
+ /** Ledger: outcomes that said it held up. Saturating — the tenth is worth almost nothing. */
30280
+ confirms: external_exports.number(),
30281
+ /** Ledger: contradictions, in signal units (a full override = 1, weaker signals less).
30282
+ * Linear and priced above the entire confirmation budget, so any full counter wins. */
30283
+ counters: external_exports.number(),
30284
+ /** The agent that asked the question this move came from, when known. Null for a move
30285
+ * distilled from a clarify ruling (those carry no agent) or one whose source rows are gone. */
30286
+ learnedFrom: external_exports.object({ id: external_exports.string(), name: external_exports.string() }).nullable()
30287
+ });
30140
30288
  var CreateRequestSchema = external_exports.object({
30141
30289
  /** The connection (token id) to send to, from GET /api/tokens. */
30142
30290
  tokenId: external_exports.string(),
@@ -30343,6 +30491,17 @@ var DeviceTokenSchema = external_exports.object({
30343
30491
  * a default silly name). */
30344
30492
  name: external_exports.string(),
30345
30493
  device: external_exports.string().nullable(),
30494
+ /** The pairing's assigned voice, cached so the desktop can seed the SAME face the phone
30495
+ * draws — voice is the third ingredient of a hatchling's build (party/traits.ts). */
30496
+ voice: external_exports.string().nullable().optional(),
30497
+ /** The token's server-side id — the face's COLOUR anchor, and the only seed ingredient
30498
+ * that survives a rename. Cached by the host's identity beat. */
30499
+ token_id: external_exports.string().nullable().optional(),
30500
+ /** WHERE this identity works — the folder a wake should land it in. Written by the host
30501
+ * at spawn and by `paigy-harness handoff` from a live terminal. Without it every wake
30502
+ * landed in the FIRST granted workspace and the agent rediscovered its own repo from
30503
+ * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
30504
+ workspace: external_exports.string().nullable().optional(),
30346
30505
  phone_reveal: PairingRevealSchema.nullable().optional(),
30347
30506
  // present once the phone reveals
30348
30507
  uik_pub: external_exports.string().nullable().optional()
@@ -30369,6 +30528,8 @@ var NotificationFeedbackKindSchema = external_exports.enum([
30369
30528
  // "There should be a picture or design here."
30370
30529
  "should_have_called",
30371
30530
  // "Don't put this in a banner — ring me for something like this."
30531
+ "should_have_messaged",
30532
+ // the inverse: "that didn't deserve a ring — a message would do."
30372
30533
  "other"
30373
30534
  // anything else — the note carries it.
30374
30535
  ]);
@@ -30665,7 +30826,11 @@ function open(envelope, myKeyId, mySecretKeyB64) {
30665
30826
  if (!eqCt(sealedCanon, expected)) throw new Error("header mismatch (tampered metadata)");
30666
30827
  return { header: envelope.hdr, body };
30667
30828
  }
30668
- var AGENT_NAME = process.env.PAIGY_AGENT ?? "mcp-agent";
30829
+ function sessionSlot(sessionId) {
30830
+ const id = sessionId ?? process.env.PAIGY_SESSION_ID ?? process.env.CLAUDE_CODE_SESSION_ID ?? randomUUID2();
30831
+ return `session:${id.slice(0, 8)}`;
30832
+ }
30833
+ var AGENT_NAME = process.env.PAIGY_AGENT || sessionSlot();
30669
30834
  var TOKEN_PATH = join2(homedir2(), ".paigy", "token.json");
30670
30835
  var KEY_PATH = join2(homedir2(), ".paigy", "key.json");
30671
30836
  function readTokenFile() {
@@ -30680,10 +30845,45 @@ function readTokenFile() {
30680
30845
  return {};
30681
30846
  }
30682
30847
  }
30683
- function saveToken(token, agent2 = AGENT_NAME) {
30684
- const slots = { ...readTokenFile(), [agent2]: token };
30848
+ function withTokenLock(mutate) {
30849
+ const lock = TOKEN_PATH + ".lock";
30850
+ const spin = new Int32Array(new SharedArrayBuffer(4));
30685
30851
  mkdirSync(join2(homedir2(), ".paigy"), { recursive: true });
30686
- writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
30852
+ for (const deadline = Date.now() + 5e3; Date.now() < deadline; ) {
30853
+ try {
30854
+ closeSync(openSync(lock, "wx"));
30855
+ break;
30856
+ } catch {
30857
+ const held = statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now();
30858
+ if (Date.now() - held > 5e3) rmSync(lock, { force: true });
30859
+ else Atomics.wait(spin, 0, 0, 25);
30860
+ }
30861
+ }
30862
+ try {
30863
+ return mutate();
30864
+ } finally {
30865
+ rmSync(lock, { force: true });
30866
+ }
30867
+ }
30868
+ function saveToken(token, agent2 = AGENT_NAME) {
30869
+ withTokenLock(() => {
30870
+ const slots = { ...readTokenFile(), [agent2]: token };
30871
+ writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
30872
+ });
30873
+ }
30874
+ function updateSlot(agent2, patch) {
30875
+ return withTokenLock(() => {
30876
+ const slots = readTokenFile();
30877
+ const existing = slots[agent2];
30878
+ if (!existing) return false;
30879
+ slots[agent2] = { ...existing, ...patch };
30880
+ writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
30881
+ return true;
30882
+ });
30883
+ }
30884
+ function slotIdentity(agent2) {
30885
+ const t = readTokenFile()[agent2];
30886
+ return { name: t?.name ?? null, voice: t?.voice ?? null, tokenId: t?.token_id ?? null, workspace: t?.workspace ?? null };
30687
30887
  }
30688
30888
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30689
30889
  function readToken(agent2 = AGENT_NAME) {
@@ -30909,7 +31109,9 @@ import { join as join5 } from "path";
30909
31109
  // ../../packages/schema/dist/index.js
30910
31110
  var ContextSchema2 = external_exports.object({
30911
31111
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
30912
- 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.")
31112
+ description: external_exports.array(external_exports.string().min(1)).describe(
31113
+ "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."
31114
+ )
30913
31115
  });
30914
31116
  var ParticipantSchema2 = external_exports.object({
30915
31117
  kind: external_exports.enum(["human", "agent"]),
@@ -31056,6 +31258,14 @@ var NotifyRequestSchema2 = external_exports.object({
31056
31258
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
31057
31259
  "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."
31058
31260
  ),
31261
+ /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
31262
+ * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
31263
+ * holding by default would charge every quiet claim that minute before any agent could
31264
+ * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
31265
+ * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
31266
+ confirm: external_exports.boolean().optional().describe(
31267
+ "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'."
31268
+ ),
31059
31269
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
31060
31270
  * choice. Outranks waiting in both directions: 'call' rings even for a
31061
31271
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -31105,6 +31315,23 @@ var NotifyRequestSchema2 = external_exports.object({
31105
31315
  if (!needsOptions && r.options?.length)
31106
31316
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
31107
31317
  });
31318
+ function unitsOf2(text) {
31319
+ const spans = [];
31320
+ const re = /\n\s*\n+/g;
31321
+ let cursor = 0;
31322
+ const push = (from, to) => {
31323
+ const slice = text.slice(from, to);
31324
+ const lead = slice.length - slice.trimStart().length;
31325
+ const tail = slice.length - slice.trimEnd().length;
31326
+ if (from + lead < to - tail) spans.push({ start: from + lead, end: to - tail });
31327
+ };
31328
+ for (let m = re.exec(text); m; m = re.exec(text)) {
31329
+ push(cursor, m.index);
31330
+ cursor = m.index + m[0].length;
31331
+ }
31332
+ push(cursor, text.length);
31333
+ return spans;
31334
+ }
31108
31335
  var NotifyStatusSchema2 = external_exports.enum(["pending", "answered", "ignored"]);
31109
31336
  var AgentStateSchema2 = external_exports.enum(["idle", "in_progress", "completed", "needs_input"]);
31110
31337
  var SetTaskStateSchema2 = external_exports.object({
@@ -31287,6 +31514,34 @@ var NotifyResponseSchema2 = external_exports.object({
31287
31514
  answer: UserAnswerSchema2.optional(),
31288
31515
  answeredAt: external_exports.string().datetime().optional()
31289
31516
  });
31517
+ var NotifyPlanUnitSchema2 = external_exports.object({
31518
+ notificationId: external_exports.string(),
31519
+ /** The unit's own heading, so the agent can tell which of its paragraphs this became. */
31520
+ title: external_exports.string(),
31521
+ /** How loudly this unit was arbitrated to arrive — per unit, which is the point of units. */
31522
+ level: NotifyLevelSchema2,
31523
+ /** Answered from something the user already decided: nobody is interrupted, and a trail card
31524
+ * says so. The agent should not wait on this one. */
31525
+ settled: external_exports.literal(true).optional(),
31526
+ /** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
31527
+ * agent — nothing here changed the ask, and ignoring it costs nothing. */
31528
+ needs: external_exports.array(external_exports.enum(["options", "visuals"])).optional(),
31529
+ /** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
31530
+ * split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
31531
+ * broker that DECIDES that destroys the only fact separating a statement from a real ask
31532
+ * (#731), so it is offered, never applied: the unit is stored `text` until the agent
31533
+ * confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
31534
+ proposal: external_exports.object({
31535
+ select: SelectShapeSchema2,
31536
+ options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
31537
+ }).optional()
31538
+ });
31539
+ var NotifyPlanSchema2 = external_exports.object({
31540
+ units: external_exports.array(NotifyPlanUnitSchema2),
31541
+ /** Which unit is this arrival's ONE interruption (units-design.md D23). Absent means nobody
31542
+ * was interrupted — every unit was either settled or quiet enough to sit in the inbox. */
31543
+ speaks: external_exports.string().optional()
31544
+ });
31290
31545
  var UserResponseSchema2 = external_exports.object({
31291
31546
  requestId: external_exports.string(),
31292
31547
  answer: UserAnswerSchema2,
@@ -31341,6 +31596,20 @@ var InboxItemSchema2 = external_exports.object({
31341
31596
  /** The conversation thread + connection this item lives on. Present on the replied
31342
31597
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
31343
31598
  parentId: external_exports.string().optional(),
31599
+ /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
31600
+ * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
31601
+ * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
31602
+ * accumulates every message an agent ever sent, so grouping by it renders a day of
31603
+ * unrelated updates as a single "12-part request". Absent on rows written before the
31604
+ * `asks` table, and on anything that never went through `notify` — both fall back to the
31605
+ * thread, which is what the client did for all rows until now. */
31606
+ askId: external_exports.string().optional(),
31607
+ /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
31608
+ * shares one `created_at` to the microsecond, so without it the author's order is
31609
+ * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
31610
+ * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
31611
+ * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
31612
+ seq: external_exports.number().int().optional(),
31344
31613
  tokenId: external_exports.string().optional(),
31345
31614
  status: NotifyStatusSchema2,
31346
31615
  context: ContextSchema2,
@@ -31351,6 +31620,16 @@ var InboxItemSchema2 = external_exports.object({
31351
31620
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
31352
31621
  * between them could not express a call as a sequence. `question: null` is a real turn —
31353
31622
  * a status update stays a statement instead of being shaped into a yes/no. */
31623
+ /** Does this claim want an ANSWER, or is it telling you something? Written per row from
31624
+ * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
31625
+ * its acknowledge affordance: without it a status update offers a text box and a dismiss,
31626
+ * and neither of those is "got it" (owner, 2026-08-10). */
31627
+ asks: external_exports.boolean().optional(),
31628
+ /** When a live process last pulsed for this row's agent — the liveness input for
31629
+ * "working requires a pulse" (#928): the list said "Working…" from agent_state alone
31630
+ * while the party called the same dead claim stalled. Absent = no token/no data,
31631
+ * which must never CLAIM stalled. */
31632
+ lastSeenAt: external_exports.string().optional(),
31354
31633
  agenda: external_exports.array(AgendaTurnSchema2).optional(),
31355
31634
  /** On a replied detail (#397): the next steps the user attached to the answer
31356
31635
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -31375,6 +31654,23 @@ var InboxItemSchema2 = external_exports.object({
31375
31654
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
31376
31655
  error: external_exports.string().optional(),
31377
31656
  clarifies: external_exports.string().optional(),
31657
+ /** The ring ladder ran out while this was still pending — we tried to reach you and
31658
+ * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
31659
+ * agent with nothing to say, which the roster drew identically until now: "nothing to
31660
+ * say" and "gave up saying it" are opposite situations wearing the same face
31661
+ * (navigation-design.md, gap 1). False for anything that never rang. */
31662
+ gaveUp: external_exports.boolean().default(false),
31663
+ /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
31664
+ * Absent for anything never delivered through a push, and for older rows written before
31665
+ * the reason was recorded. Deliberately a debug affordance, shown small (owner,
31666
+ * 2026-08-07) — its real job is to give "this didn't need a call" something to be
31667
+ * feedback ABOUT. */
31668
+ why: external_exports.object({
31669
+ asked: NotifyLevelSchema2,
31670
+ got: NotifyLevelSchema2,
31671
+ because: external_exports.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
31672
+ line: external_exports.string()
31673
+ }).optional(),
31378
31674
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
31379
31675
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
31380
31676
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -31496,6 +31792,14 @@ var ConnectionSummarySchema2 = external_exports.object({
31496
31792
  provider: external_exports.string().nullable(),
31497
31793
  /** The pairing's assigned voice (#462); null = the default voice. */
31498
31794
  voice: VoiceKeySchema2.nullable(),
31795
+ /** The LOUDEST this agent may ever reach you — a ceiling on `NOTIFY_LADDER`, set by the
31796
+ * user on the agent's own page. null = no ceiling (today's behaviour for every
31797
+ * connection). Android binds importance to a relationship rather than to each message,
31798
+ * and that is the thing our roster could not say: "Marlow may always call me; Otto
31799
+ * never may" (navigation-design.md, gap 2). Clamped in `arbitrateLevel`, so it binds
31800
+ * every surface at once and outranks even `sessionMode: all_calls` — a mode the user
31801
+ * set once must not overrule a rule they set about one agent. */
31802
+ reach: NotifyLevelSchema2.nullable().optional(),
31499
31803
  createdAt: external_exports.string().datetime(),
31500
31804
  /** Most recent notification on this connection, either direction. Null = no contact yet.
31501
31805
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -31514,6 +31818,40 @@ var ConnectionSummarySchema2 = external_exports.object({
31514
31818
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
31515
31819
  managed: external_exports.boolean()
31516
31820
  });
31821
+ var MoveRingSchema2 = external_exports.enum(["home", "travels", "retired", "quarantined"]);
31822
+ var MoveSchema2 = external_exports.object({
31823
+ id: external_exports.string(),
31824
+ /** The reusable question, as distill normalized it. */
31825
+ question: external_exports.string(),
31826
+ /** The operative ruling. Editable by the user (PATCH) — which resets the ledger. */
31827
+ answer: external_exports.string(),
31828
+ /** The user's stated reason, when they gave one. Null = inherently narrow: the judge is
31829
+ * told so, and the ruling only derives essentially the same question in the same scope. */
31830
+ rationale: external_exports.string().nullable(),
31831
+ /** Where the ruling lives: a repo/workspace, or 'global'. */
31832
+ scope: external_exports.string(),
31833
+ ring: MoveRingSchema2,
31834
+ /** True = the user pinned it with `always` (travel granted by hand, not by evidence). */
31835
+ pinned: external_exports.boolean(),
31836
+ /** True = a pin the user placed was BROKEN by later counter-evidence. Surfaced so the
31837
+ * break is visible instead of a pin silently disappearing. */
31838
+ pinBroken: external_exports.boolean(),
31839
+ /** When the ruling was distilled. */
31840
+ learnedAt: external_exports.string(),
31841
+ /** Last time it answered an ask. Null = never fired. */
31842
+ lastUsedAt: external_exports.string().nullable(),
31843
+ /** How many asks it has answered. Instrumentation — deliberately NOT an input to the
31844
+ * evidence curve: firing says the question keeps arising, not that the ruling is right. */
31845
+ usedCount: external_exports.number(),
31846
+ /** Ledger: outcomes that said it held up. Saturating — the tenth is worth almost nothing. */
31847
+ confirms: external_exports.number(),
31848
+ /** Ledger: contradictions, in signal units (a full override = 1, weaker signals less).
31849
+ * Linear and priced above the entire confirmation budget, so any full counter wins. */
31850
+ counters: external_exports.number(),
31851
+ /** The agent that asked the question this move came from, when known. Null for a move
31852
+ * distilled from a clarify ruling (those carry no agent) or one whose source rows are gone. */
31853
+ learnedFrom: external_exports.object({ id: external_exports.string(), name: external_exports.string() }).nullable()
31854
+ });
31517
31855
  var CreateRequestSchema2 = external_exports.object({
31518
31856
  /** The connection (token id) to send to, from GET /api/tokens. */
31519
31857
  tokenId: external_exports.string(),
@@ -31720,6 +32058,17 @@ var DeviceTokenSchema2 = external_exports.object({
31720
32058
  * a default silly name). */
31721
32059
  name: external_exports.string(),
31722
32060
  device: external_exports.string().nullable(),
32061
+ /** The pairing's assigned voice, cached so the desktop can seed the SAME face the phone
32062
+ * draws — voice is the third ingredient of a hatchling's build (party/traits.ts). */
32063
+ voice: external_exports.string().nullable().optional(),
32064
+ /** The token's server-side id — the face's COLOUR anchor, and the only seed ingredient
32065
+ * that survives a rename. Cached by the host's identity beat. */
32066
+ token_id: external_exports.string().nullable().optional(),
32067
+ /** WHERE this identity works — the folder a wake should land it in. Written by the host
32068
+ * at spawn and by `paigy-harness handoff` from a live terminal. Without it every wake
32069
+ * landed in the FIRST granted workspace and the agent rediscovered its own repo from
32070
+ * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
32071
+ workspace: external_exports.string().nullable().optional(),
31723
32072
  phone_reveal: PairingRevealSchema2.nullable().optional(),
31724
32073
  // present once the phone reveals
31725
32074
  uik_pub: external_exports.string().nullable().optional()
@@ -31746,6 +32095,8 @@ var NotificationFeedbackKindSchema2 = external_exports.enum([
31746
32095
  // "There should be a picture or design here."
31747
32096
  "should_have_called",
31748
32097
  // "Don't put this in a banner — ring me for something like this."
32098
+ "should_have_messaged",
32099
+ // the inverse: "that didn't deserve a ring — a message would do."
31749
32100
  "other"
31750
32101
  // anything else — the note carries it.
31751
32102
  ]);
@@ -31860,6 +32211,9 @@ function levelFor(event) {
31860
32211
  case "idle":
31861
32212
  if (endsWithQuestion(event.result)) return "banner";
31862
32213
  return event.failed ? "push" : "inbox";
32214
+ case "work":
32215
+ return "inbox";
32216
+ // moot — entryFor drops work before a level ever matters
31863
32217
  case "error":
31864
32218
  return "inbox";
31865
32219
  }
@@ -31880,16 +32234,14 @@ function entryFor(event, state = {}) {
31880
32234
  switch (event.kind) {
31881
32235
  case "turn": {
31882
32236
  const who = event.role === "agent" ? "Agent" : "You";
31883
- const title = event.text ? `${who}: ${firstLine(event.text)}` : `${who} ran ${(event.tools ?? []).join(", ")}`;
31884
- const description = [event.text, event.tools?.length ? `Tools: ${event.tools.join(", ")}` : null].filter((c) => Boolean(c));
32237
+ const body = event.text ? event.role === "agent" ? event.text : `${who}: ${event.text}` : `${who} ran ${(event.tools ?? []).join(", ")}.`;
31885
32238
  return {
31886
32239
  ...common,
31887
- context: { title: clip(title), description: description.length ? description : [title] },
31888
- // The shaped form always carries an answer shape the contract's rule, and the
31889
- // right one: any inbox row can be replied to, and a reply to history is just
31890
- // the user initiating (the pump feeds it back in). Non-blocking, so it never
31891
- // reads as a question.
31892
- select: "text"
32240
+ ask: body
32241
+ // No `select`: the contract refuses it on the `ask` form ("the broker derives it"),
32242
+ // and with no options it derives "text" anyway the shape this wants, since any
32243
+ // inbox row can be replied to and a reply to history is just the user initiating
32244
+ // (the pump feeds it back in).
31893
32245
  };
31894
32246
  }
31895
32247
  case "permission":
@@ -31911,16 +32263,24 @@ function entryFor(event, state = {}) {
31911
32263
  };
31912
32264
  case "idle": {
31913
32265
  const asking = endsWithQuestion(event.result);
32266
+ const result = event.result?.trim();
32267
+ const norm = (t) => (t ?? "").replace(/\s+/g, " ").trim();
32268
+ if (result && !event.failed && norm(result) === norm(state.lastAgentText)) {
32269
+ if (!asking) return null;
32270
+ const spans = unitsOf2(result);
32271
+ const tail = spans.map((sp) => result.slice(sp.start, sp.end)).reverse().find((t) => t.includes("?"));
32272
+ return { ...common, ask: tail ?? result, blocking: true };
32273
+ }
32274
+ const lead = asking ? "" : event.failed ? "The agent stopped without finishing. " : "Turn complete. ";
31914
32275
  return {
31915
32276
  ...common,
31916
- context: {
31917
- title: asking ? clip(firstLine(event.result ?? "")) : event.failed ? "The agent stopped without finishing" : "Turn complete",
31918
- description: [event.result || (event.failed ? "No result reported." : "Done.")]
31919
- },
31920
- select: "text",
32277
+ ask: `${lead}${result || (event.failed ? "No result reported." : "Done.")}`,
31921
32278
  ...asking ? { blocking: true } : {}
31922
32279
  };
31923
32280
  }
32281
+ case "work":
32282
+ return null;
32283
+ // log-only by contract — the live texture of the working log
31924
32284
  case "error":
31925
32285
  return null;
31926
32286
  }
@@ -31942,9 +32302,16 @@ function decisionFrom(answer) {
31942
32302
  }
31943
32303
  async function mirror(event, state, deps = {}) {
31944
32304
  const entry = entryFor(event, state);
32305
+ if (event.kind === "turn" && event.role === "agent" && event.text) state.lastAgentText = event.text;
31945
32306
  if (!entry) return {};
32307
+ let req;
32308
+ try {
32309
+ req = NotifyRequestSchema2.parse(entry);
32310
+ } catch (e) {
32311
+ 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)}`);
32312
+ return {};
32313
+ }
31946
32314
  try {
31947
- const req = NotifyRequestSchema2.parse(entry);
31948
32315
  const { notificationId, parentId } = await (deps.submit ?? submitNotification)(req);
31949
32316
  (state.mine ??= /* @__PURE__ */ new Set()).add(notificationId);
31950
32317
  return { parentId, notificationId };
@@ -31973,8 +32340,9 @@ async function askQuestion(question, state, deps = {}) {
31973
32340
  ...state.parentId ? { parentId: state.parentId } : {},
31974
32341
  ...state.repo ? { repo: state.repo } : {},
31975
32342
  ...state.branch ? { branch: state.branch } : {},
31976
- context: { title: clip(firstLine(question)), description: [question] },
31977
- select: "text",
32343
+ // Prose in see `entryFor`'s `turn` case. An agent's question is the case most likely
32344
+ // to run long, and its title was a clipped prefix of itself.
32345
+ ask: question,
31978
32346
  blocking: true,
31979
32347
  urgency: "banner"
31980
32348
  };
@@ -31988,6 +32356,9 @@ async function askQuestion(question, state, deps = {}) {
31988
32356
  }
31989
32357
  async function nudgeSetup(label, hint, deps = {}) {
31990
32358
  const entry = {
32359
+ // Keeps `context`: the title here SUMMARISES rather than repeating — "<label> needs
32360
+ // setup" is not a prefix of the hint — which is exactly the case a hand-written context
32361
+ // is for. `ask` would derive a title from the hint's first sentence and lose the label.
31991
32362
  context: { title: clip(`${label} needs setup`), description: [hint] },
31992
32363
  select: "text",
31993
32364
  urgency: "push"
@@ -32020,7 +32391,6 @@ function spokenText(answer) {
32020
32391
  return null;
32021
32392
  }
32022
32393
  }
32023
- var firstLine = (s) => s.split("\n")[0] ?? s;
32024
32394
  var clip = (s) => (s.length > 120 ? `${s.slice(0, 117)}\u2026` : s) || "(no text)";
32025
32395
 
32026
32396
  // src/harness/session.ts
@@ -32055,6 +32425,14 @@ var AcpDriver = class {
32055
32425
  queued = [];
32056
32426
  /** Options of each unanswered permission request, keyed by its JSON-RPC id. */
32057
32427
  pending = /* @__PURE__ */ new Map();
32428
+ /** Where the REPORT starts in `text` — everything before the LAST tool call is working
32429
+ * narration ("Now the API endpoints." → runs a tool), and it used to ship: the chunks
32430
+ * concatenate with no separator, so the owner's phone got "…find the repo.Now I have
32431
+ * the full picture. Writing the migration.Now…" as the opening paragraph of a finished
32432
+ * task (live, 2026-08-11 — "looks like a working log"). The narration's audience is the
32433
+ * terminal and host.log; what the agent composed AFTER its last tool call is the part
32434
+ * addressed to a human, and that is what leaves the machine. */
32435
+ reportFrom = 0;
32058
32436
  /** The turn being streamed: text accumulates, tools append, both flush on stopReason. */
32059
32437
  text = "";
32060
32438
  tools = [];
@@ -32150,18 +32528,20 @@ var AcpDriver = class {
32150
32528
  if (msg.id === this.promptId) {
32151
32529
  this.promptId = void 0;
32152
32530
  const events = [];
32153
- if (this.text.trim() || this.tools.length) {
32531
+ const report = this.text.slice(this.reportFrom).trim() || this.text.trim();
32532
+ if (report || this.tools.length) {
32154
32533
  events.push({
32155
32534
  kind: "turn",
32156
32535
  role: "agent",
32157
- text: this.text.trim(),
32536
+ text: report,
32158
32537
  ...this.tools.length ? { tools: [...this.tools] } : {}
32159
32538
  });
32160
32539
  }
32161
- const result = this.text.trim();
32540
+ const result = report;
32162
32541
  const failed = msg.error !== void 0 || msg.result?.stopReason === "refusal";
32163
32542
  this.text = "";
32164
32543
  this.tools = [];
32544
+ this.reportFrom = 0;
32165
32545
  const writes = this.flush();
32166
32546
  if (!writes.length) {
32167
32547
  events.push({
@@ -32185,7 +32565,9 @@ var AcpDriver = class {
32185
32565
  case "tool_call": {
32186
32566
  const title = update.title?.trim() || update.kind || "tool";
32187
32567
  this.tools.push(title);
32188
- return none;
32568
+ const note = this.text.slice(this.reportFrom).trim();
32569
+ this.reportFrom = this.text.length;
32570
+ return { events: [{ kind: "work", tool: title, ...note ? { note } : {} }], writes: [] };
32189
32571
  }
32190
32572
  default:
32191
32573
  return none;
@@ -32239,7 +32621,8 @@ var frame = (body) => JSON.stringify({ jsonrpc: "2.0", ...body });
32239
32621
  // src/harness/session.ts
32240
32622
  var ADAPTER_BIN = {
32241
32623
  claude: "claude-agent-acp",
32242
- codex: "codex-acp"
32624
+ codex: "codex-acp",
32625
+ agy: "agy"
32243
32626
  };
32244
32627
  function splitLines(buffer, chunk) {
32245
32628
  const combined = buffer + chunk;
@@ -32337,6 +32720,10 @@ function runHarness(opts) {
32337
32720
  opts.log(decision.allow ? `\u2713 approved: ${event.summary}` : `\u2717 denied: ${event.summary}`);
32338
32721
  return;
32339
32722
  }
32723
+ if (event.kind === "work") {
32724
+ opts.log(`\u2699 ${event.tool}${event.note ? ` \u2014 ${event.note.split("\n")[0] ?? ""}` : ""}`);
32725
+ return;
32726
+ }
32340
32727
  const { parentId } = await mirror(event, state, deps);
32341
32728
  state.parentId ??= parentId;
32342
32729
  if (event.kind === "turn") opts.log(`${event.role}: ${event.text.split("\n")[0] ?? ""}`);
@@ -32419,22 +32806,39 @@ function addWorkspace(dir, deps) {
32419
32806
  }
32420
32807
  return all;
32421
32808
  }
32809
+ function allowed(cwd, deps) {
32810
+ const target = resolve2(cwd.replace(/^~(?=$|\/)/, homedir4()));
32811
+ return listWorkspaces(deps).some((w) => target === w || target.startsWith(`${w}/`));
32812
+ }
32813
+ function resolveWakeDir(pinned, deps) {
32814
+ if (pinned && allowed(pinned, deps)) {
32815
+ const dir = resolve2(pinned.replace(/^~(?=$|\/)/, homedir4()));
32816
+ if ((deps.exists ?? existsSync4)(dir)) return dir;
32817
+ }
32818
+ return listWorkspaces(deps)[0];
32819
+ }
32422
32820
 
32423
32821
  // src/host.ts
32424
32822
  var HOST_FILE = join4(homedir5(), ".paigy", "host.json");
32425
32823
  function startHost(opts) {
32426
32824
  const runs = /* @__PURE__ */ new Map();
32427
32825
  const asHost = { token: opts.token };
32428
- const refreshNames = async () => {
32826
+ const refreshIdentities = async () => {
32429
32827
  for (const slot of listSlots()) {
32430
32828
  const token = readToken(slot);
32431
32829
  if (!token) continue;
32432
32830
  const me = await whoAmI({ token }).catch(() => null);
32433
- if (me?.name && me.name !== slotName(slot)) saveToken({ access_token: token, name: me.name, device: null }, slot);
32831
+ if (!me) continue;
32832
+ const have = slotIdentity(slot);
32833
+ if (me.name !== have.name || (me.voice ?? null) !== have.voice || (me.tokenId ?? null) !== have.tokenId) {
32834
+ updateSlot(slot, { ...me.name ? { name: me.name } : {}, voice: me.voice ?? null, token_id: me.tokenId ?? null });
32835
+ }
32434
32836
  }
32435
32837
  };
32436
32838
  const beat = () => {
32437
- void refreshNames();
32839
+ void refreshIdentities();
32840
+ for (const r of runs.values()) if (r.token) void heartbeat(void 0, { token: r.token }).catch(() => {
32841
+ });
32438
32842
  void heartbeat({
32439
32843
  harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status })),
32440
32844
  workspaces: listWorkspaces(opts.wsDeps)
@@ -32459,25 +32863,28 @@ function startHost(opts) {
32459
32863
  },
32460
32864
  log
32461
32865
  });
32462
- runs.set(spec.sessionId, { run, label });
32866
+ runs.set(spec.sessionId, { run, label, token: spec.token });
32867
+ void heartbeat(void 0, { token: spec.token }).catch(() => {
32868
+ });
32869
+ void refreshIdentities();
32463
32870
  try {
32464
- saveToken({ access_token: spec.token, name: label, device: null }, `session:${spec.sessionId.slice(0, 8)}`);
32871
+ saveToken({ access_token: spec.token, name: label, device: null, workspace: spec.workspace }, sessionSlot(spec.sessionId));
32465
32872
  } catch {
32466
32873
  }
32467
32874
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
32468
32875
  }
32469
32876
  }
32470
- const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex" };
32471
- 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.";
32877
+ const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex", antigravity: "agy" };
32878
+ const WAKE_PROMPT = "You were woken because Paigy work is waiting for you. This is a fresh session with your existing identity, so you may already have work in flight that you can't remember. Call check_replies FIRST, then get_thread on each conversation it returns and read it before you touch anything: it holds what you were doing, where (a repo or worktree may not be this folder), and what the owner already decided. Then handle what's waiting and follow your paigy instructions to stay in the loop. Speak to the owner at exactly TWO moments: when you are BLOCKED on a decision only they can make (contact with waiting:'hard', the decision as the ask, options if you have real ones), and when you are DONE (one short report: what shipped, how you verified it, anything you flagged). Progress is never a contact \u2014 call set_task_state('in_progress') when you pick work up and the app shows you working; narrate to the terminal, not to the human.";
32472
32879
  async function sweepSlots() {
32473
- const workspace = listWorkspaces(opts.wsDeps)[0];
32474
- if (!workspace) return;
32475
32880
  for (const slot of listSlots()) {
32476
32881
  const harness = SLOT_HARNESS[slot] ?? (slot === "Desktop" ? void 0 : "claude");
32477
32882
  const key = `slot:${slot}`;
32478
32883
  if (!harness || runs.has(key)) continue;
32479
32884
  const token = readToken(slot);
32480
32885
  if (!token) continue;
32886
+ const workspace = resolveWakeDir(slotIdentity(slot).workspace, opts.wsDeps);
32887
+ if (!workspace) continue;
32481
32888
  const work = await checkReplies({ token }).catch(() => null);
32482
32889
  if (!work || work.requests.length === 0 && work.replies.length === 0) continue;
32483
32890
  const label = slotName(slot) ?? slot;
@@ -32496,7 +32903,9 @@ function startHost(opts) {
32496
32903
  },
32497
32904
  log
32498
32905
  });
32499
- runs.set(key, { run, label });
32906
+ runs.set(key, { run, label, token });
32907
+ void heartbeat(void 0, { token }).catch(() => {
32908
+ });
32500
32909
  opts.log(`\u25B6 woke ${label} (${harness}) \u2014 work was waiting in ${workspace}`);
32501
32910
  }
32502
32911
  }
@@ -32529,12 +32938,21 @@ function startHost(opts) {
32529
32938
  const live = new Map([...runs.values()].map((r) => [r.label, r.run]));
32530
32939
  const names = /* @__PURE__ */ new Map();
32531
32940
  for (const key of listSlots()) if (key !== "Desktop") names.set(slotName(key) ?? key, key);
32532
- for (const label of live.keys()) if (!names.has(label)) names.set(label, label);
32533
- return [...names.keys()].map((name) => ({
32534
- name,
32535
- running: live.has(name),
32536
- working: live.get(name)?.working() ?? false
32537
- }));
32941
+ for (const label of live.keys()) if (!names.has(label)) names.set(label, null);
32942
+ return [...names.entries()].map(([name, slot]) => {
32943
+ const id = slot ? slotIdentity(slot) : { voice: null, tokenId: null };
32944
+ return {
32945
+ name,
32946
+ // The slot key rides along so the window's agent page can speak AS this agent —
32947
+ // its pending reads (`checkReplies`, the same pure read the sweep does) need the
32948
+ // slot's own token, and the display name is not a key.
32949
+ slot,
32950
+ tokenId: id.tokenId,
32951
+ voice: id.voice,
32952
+ running: live.has(name),
32953
+ working: live.get(name)?.working() ?? false
32954
+ };
32955
+ });
32538
32956
  },
32539
32957
  stopSessions,
32540
32958
  stop() {
@@ -32553,14 +32971,14 @@ function startHost(opts) {
32553
32971
 
32554
32972
  // src/cli.ts
32555
32973
  function parseArgs(argv) {
32556
- const args = { harness: "claude", mode: "bypass", cwd: process.cwd(), doctor: false, host: false, pair: false, service: false, setup: false, grant: [], hatch: null, voice: null, slot: null, identity: null, graceSeconds: 90, prompt: "" };
32974
+ const args = { harness: "claude", mode: "bypass", cwd: process.cwd(), doctor: false, host: false, pair: false, service: false, setup: false, grant: [], hatch: null, voice: null, slot: null, identity: null, graceSeconds: 90, prompt: "", handoff: false };
32557
32975
  const words = [];
32558
32976
  for (let i = 0; i < argv.length; i++) {
32559
32977
  const arg = argv[i];
32560
32978
  const next = () => argv[++i];
32561
32979
  if (arg === "--harness") {
32562
32980
  const v = next();
32563
- if (v !== "claude" && v !== "codex") return { error: `--harness must be claude or codex, got ${v ?? "nothing"}` };
32981
+ if (v !== "claude" && v !== "codex" && v !== "agy") return { error: `--harness must be claude, codex, or agy, got ${v ?? "nothing"}` };
32564
32982
  args.harness = v;
32565
32983
  } else if (arg === "--mode") {
32566
32984
  const v = next();
@@ -32588,6 +33006,8 @@ function parseArgs(argv) {
32588
33006
  const v = next();
32589
33007
  if (!v) return { error: "--grant needs a folder to allow" };
32590
33008
  args.grant.push(v);
33009
+ } else if (arg === "handoff") {
33010
+ args.handoff = true;
32591
33011
  } else if (arg === "hatch") {
32592
33012
  const v = next();
32593
33013
  if (!v) return { error: 'hatch needs a name: paigy-harness hatch "Voice bug hunter"' };
@@ -32607,7 +33027,7 @@ function parseArgs(argv) {
32607
33027
  }
32608
33028
  }
32609
33029
  args.prompt = words.join(" ");
32610
- if (!args.doctor && !args.hatch && !args.host && !args.pair && !args.service && !args.setup && !args.prompt) return { error: "a prompt is required (or setup / --doctor / pair / host / service / hatch NAME)" };
33030
+ if (!args.doctor && !args.handoff && !args.hatch && !args.host && !args.pair && !args.service && !args.setup && !args.prompt) return { error: "a prompt is required (or setup / --doctor / pair / host / service / hatch NAME / handoff)" };
32611
33031
  return args;
32612
33032
  }
32613
33033
  var STATUS_MARK = { ready: "\u2713", login: "\u25D0", "adapter-missing": "\u25D0", missing: "\u2717" };
@@ -32615,7 +33035,7 @@ async function main() {
32615
33035
  const parsed = parseArgs(process.argv.slice(2));
32616
33036
  if ("error" in parsed) {
32617
33037
  console.error(`paigy-harness: ${parsed.error}`);
32618
- console.error("usage: paigy-harness setup | pair | host [--grant DIR]\u2026 | service | hatch NAME [--voice KEY] [--slot SLOT] | [--harness claude|codex] [--mode bypass|ask] [--cwd DIR] [--identity NAME] [--grace SECONDS] [--doctor] PROMPT\u2026");
33038
+ console.error("usage: paigy-harness setup | pair | host [--grant DIR]\u2026 | service | hatch NAME [--voice KEY] [--slot SLOT] | handoff | [--harness claude|codex|agy] [--mode bypass|ask] [--cwd DIR] [--identity NAME] [--grace SECONDS] [--doctor] PROMPT\u2026");
32619
33039
  process.exit(2);
32620
33040
  }
32621
33041
  if (parsed.doctor) {
@@ -32676,9 +33096,12 @@ async function main() {
32676
33096
  const TERMINAL_AGENTS = [
32677
33097
  // Wire = MCP registration + the plugin (skills, hooks, idle-escalation — the
32678
33098
  // piece that makes a LIVE session notice your requests without polling).
32679
- { cli: "claude", name: "Claude Code", slot: "mcp-agent", wire: "claude mcp add --scope user paigy -- npx -y @paigy/mcp@latest && claude plugin marketplace add paigy-ai/mcp; claude plugin install paigy@paigy" },
32680
- { cli: "codex", name: "Codex", slot: "codex", wire: "codex mcp add paigy -- npx -y @paigy/mcp@latest" },
32681
- { cli: "antigravity", name: "Antigravity", slot: "antigravity" }
33099
+ // The slot goes in the MCP config as PAIGY_AGENT, always. A registration without it
33100
+ // used to fall through to a shared default slot, so every unconfigured session on the
33101
+ // machine spoke as whoever paired into it last (strict identity, 2026-08-07).
33102
+ { cli: "claude", name: "Claude Code", slot: "mcp-agent", wire: "claude mcp add --scope user -e PAIGY_AGENT=mcp-agent paigy -- npx -y @paigy/mcp@latest && claude plugin marketplace add paigy-ai/mcp; claude plugin install paigy@paigy" },
33103
+ { cli: "codex", name: "Codex", slot: "codex", wire: "codex mcp add paigy --env PAIGY_AGENT=codex -- npx -y @paigy/mcp@latest" },
33104
+ { cli: "agy", name: "Antigravity", slot: "antigravity" }
32682
33105
  ];
32683
33106
  overrideToken(readToken("Desktop") || null);
32684
33107
  for (const t of TERMINAL_AGENTS) {
@@ -32753,6 +33176,9 @@ async function main() {
32753
33176
  </array>
32754
33177
  <key>RunAtLoad</key><true/>
32755
33178
  <key>KeepAlive</key><true/>
33179
+ <key>SoftResourceLimits</key><dict>
33180
+ <key>NumberOfFiles</key><integer>65536</integer>
33181
+ </dict>
32756
33182
  <key>StandardOutPath</key><string>${join5(homedir6(), ".paigy", "host.log")}</string>
32757
33183
  <key>StandardErrorPath</key><string>${join5(homedir6(), ".paigy", "host.log")}</string>
32758
33184
  </dict></plist>
@@ -32763,12 +33189,33 @@ async function main() {
32763
33189
  writeFileSync4(path, plist);
32764
33190
  execSync(`launchctl unload ${path} 2>/dev/null; launchctl load ${path}`, { shell: "/bin/sh" });
32765
33191
  console.log(`\u2713 host installed as a login service (${path}) \u2014 logs at ~/.paigy/host.log`);
33192
+ console.log(" file limit raised to 65536 for the host and every session it spawns");
32766
33193
  return;
32767
33194
  }
32768
33195
  if (!readToken("Desktop") && !readToken()) {
32769
33196
  console.error("Not paired. Run `paigy-harness pair` (or `npx -y -p @paigy/mcp paigy-mcp-onboard`), then retry.");
32770
33197
  process.exit(1);
32771
33198
  }
33199
+ if (parsed.handoff) {
33200
+ const slot = AGENT_NAME;
33201
+ if (!readToken(slot)) {
33202
+ console.error(`This terminal holds no Paigy identity (slot "${slot}" is empty) \u2014 pair or hatch first.`);
33203
+ process.exit(1);
33204
+ }
33205
+ const cwd = process.cwd();
33206
+ if (!updateSlot(slot, { workspace: cwd })) {
33207
+ console.error(`Slot "${slot}" isn't in this machine's slot file \u2014 this session was spawned by the host, which already owns its wake dir. Run handoff from the terminal that hatched the identity.`);
33208
+ process.exit(1);
33209
+ }
33210
+ const name = slotName(slot) ?? slot;
33211
+ console.log(`\u2713 ${name} is handed off \u2014 wakes in ${cwd.replace(homedir6(), "~")}`);
33212
+ if (!allowed(cwd, { file: workspacesFile() })) {
33213
+ console.log(` \u26A0 that folder isn't on the allow-list, so wakes will land in the first granted`);
33214
+ console.log(` workspace instead \u2014 add it in the desktop app, or: paigy-harness host --grant ${cwd}`);
33215
+ }
33216
+ console.log(` Write to ${name} from your phone and the host resumes it there, thread as memory.`);
33217
+ return;
33218
+ }
32772
33219
  if (parsed.hatch) {
32773
33220
  overrideToken(readToken("Desktop") || null);
32774
33221
  const minted = await hatch(parsed.hatch, parsed.voice ?? null);