@paigy/harness 0.2.8 → 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 +582 -78
  3. package/dist/main.js +598 -72
  4. package/package.json +4 -3
package/dist/main.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
1
2
  import {
2
3
  __commonJS,
3
4
  __export,
4
5
  __require,
5
6
  __toESM
6
- } from "./chunk-4VNS5WPM.js";
7
+ } from "./chunk-OBWUN26Y.js";
7
8
 
8
9
  // ../../node_modules/.pnpm/qrcode@1.5.4/node_modules/qrcode/lib/can-promise.js
9
10
  var require_can_promise = __commonJS({
@@ -4576,11 +4577,13 @@ var require_lib = __commonJS({
4576
4577
  // src/main.ts
4577
4578
  import { app, BrowserWindow, dialog, ipcMain } from "electron";
4578
4579
  import { spawn as spawn2 } from "child_process";
4580
+ import { readFileSync as readFileSync4 } from "fs";
4579
4581
  import { fileURLToPath } from "url";
4580
- import { dirname as dirname4, join as join4 } from "path";
4582
+ import { dirname as dirname4, join as join5 } from "path";
4581
4583
 
4582
4584
  // ../../packages/sdk/dist/index.js
4583
4585
  import { createRequire as __sdkCreateRequire } from "module";
4586
+ import { randomUUID } from "crypto";
4584
4587
 
4585
4588
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
4586
4589
  var external_exports = {};
@@ -8624,7 +8627,8 @@ var coerce = {
8624
8627
  var NEVER = INVALID;
8625
8628
 
8626
8629
  // ../../packages/sdk/dist/index.js
8627
- 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";
8628
8632
  import { homedir } from "os";
8629
8633
  import { join } from "path";
8630
8634
  var require2 = __sdkCreateRequire(import.meta.url);
@@ -10888,18 +10892,27 @@ var PROXY_ENV = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
10888
10892
  var agent;
10889
10893
  async function proxy() {
10890
10894
  if (!PROXY_ENV.some((k) => process.env[k])) return void 0;
10891
- return agent ??= new (await import("./undici-VGSXV7MF.js")).EnvHttpProxyAgent();
10895
+ return agent ??= new (await import("./undici-GCFUZISI.js")).EnvHttpProxyAgent();
10892
10896
  }
10897
+ var INSTANCE_ID = randomUUID();
10898
+ var SESSION_ID = process.env.PAIGY_SESSION_ID ?? process.env.CLAUDE_CODE_SESSION_ID ?? INSTANCE_ID;
10893
10899
  async function reach(url, init) {
10894
10900
  try {
10895
- 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() });
10896
10907
  } catch (e) {
10897
10908
  throw new Error(`${NETWORK_MSG} (${e?.message ?? String(e)})`);
10898
10909
  }
10899
10910
  }
10900
10911
  var ContextSchema = external_exports.object({
10901
10912
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
10902
- 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
+ )
10903
10916
  });
10904
10917
  var ParticipantSchema = external_exports.object({
10905
10918
  kind: external_exports.enum(["human", "agent"]),
@@ -11046,6 +11059,14 @@ var NotifyRequestSchema = external_exports.object({
11046
11059
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
11047
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."
11048
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
+ ),
11049
11070
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
11050
11071
  * choice. Outranks waiting in both directions: 'call' rings even for a
11051
11072
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -11104,35 +11125,45 @@ function normalizeWaiting(req) {
11104
11125
  blocking: req.blocking || waiting === "hard"
11105
11126
  };
11106
11127
  }
11107
- var DERIVE_CHUNKS_MAX = 8;
11108
- var DERIVE_CHUNK_MAX = 300;
11109
- 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) {
11110
11150
  const body = text.trim();
11111
- if (!body) return [];
11112
- const sentences = body.match(/[^.!?]+[.!?]*\s*/g) ?? [body];
11113
- const chunks = [];
11114
- for (const raw of sentences) {
11115
- const s = raw.trim();
11116
- if (!s) continue;
11117
- const last = chunks[chunks.length - 1];
11118
- if (last !== void 0 && `${last} ${s}`.length <= max) chunks[chunks.length - 1] = `${last} ${s}`;
11119
- else chunks.push(s);
11120
- }
11121
- if (chunks.length <= cap) return chunks;
11122
- 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] };
11123
11156
  }
11124
11157
  function deriveAsk(req) {
11125
11158
  req = normalizeWaiting(req);
11126
11159
  if (!req.ask) return req;
11127
11160
  const text = req.ask.trim();
11128
- const firstSentence = (/^[^.!?\n]+[.!?]?/.exec(text)?.[0] ?? text).trim();
11129
- const title = firstSentence.length > 90 ? `${firstSentence.slice(0, 87).trimEnd()}\u2026` : firstSentence;
11130
11161
  const hinted = req.urgencyHint === "now" ? "call" : req.urgencyHint === "soon" ? "banner" : req.urgencyHint === "whenever" ? "inbox" : req.urgency;
11131
11162
  const urgency = req.channel === "call" ? "call" : req.channel === "message" && hinted === "call" ? "banner" : hinted;
11132
11163
  const { ask: _ask, needs, urgencyHint: _hint, channel: _channel, ...rest } = req;
11133
11164
  return {
11134
11165
  ...rest,
11135
- context: { title, description: chunkAsk(text) },
11166
+ context: bodyAfterHeadline(text),
11136
11167
  // Options riding alongside the ask (#575: pixels can't be prose) floor to a
11137
11168
  // single pick — the model broker may upgrade to many/rank from the wording.
11138
11169
  select: req.options?.length ? "one" : "text",
@@ -11322,6 +11353,34 @@ var NotifyResponseSchema = external_exports.object({
11322
11353
  answer: UserAnswerSchema.optional(),
11323
11354
  answeredAt: external_exports.string().datetime().optional()
11324
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
+ });
11378
+ var NotifyPlanSchema = external_exports.object({
11379
+ units: external_exports.array(NotifyPlanUnitSchema),
11380
+ /** Which unit is this arrival's ONE interruption (units-design.md D23). Absent means nobody
11381
+ * was interrupted — every unit was either settled or quiet enough to sit in the inbox. */
11382
+ speaks: external_exports.string().optional()
11383
+ });
11325
11384
  var UserResponseSchema = external_exports.object({
11326
11385
  requestId: external_exports.string(),
11327
11386
  answer: UserAnswerSchema,
@@ -11376,6 +11435,20 @@ var InboxItemSchema = external_exports.object({
11376
11435
  /** The conversation thread + connection this item lives on. Present on the replied
11377
11436
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
11378
11437
  parentId: external_exports.string().optional(),
11438
+ /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
11439
+ * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
11440
+ * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
11441
+ * accumulates every message an agent ever sent, so grouping by it renders a day of
11442
+ * unrelated updates as a single "12-part request". Absent on rows written before the
11443
+ * `asks` table, and on anything that never went through `notify` — both fall back to the
11444
+ * thread, which is what the client did for all rows until now. */
11445
+ askId: external_exports.string().optional(),
11446
+ /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
11447
+ * shares one `created_at` to the microsecond, so without it the author's order is
11448
+ * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
11449
+ * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
11450
+ * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
11451
+ seq: external_exports.number().int().optional(),
11379
11452
  tokenId: external_exports.string().optional(),
11380
11453
  status: NotifyStatusSchema,
11381
11454
  context: ContextSchema,
@@ -11386,6 +11459,16 @@ var InboxItemSchema = external_exports.object({
11386
11459
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
11387
11460
  * between them could not express a call as a sequence. `question: null` is a real turn —
11388
11461
  * a status update stays a statement instead of being shaped into a yes/no. */
11462
+ /** Does this claim want an ANSWER, or is it telling you something? Written per row from
11463
+ * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
11464
+ * its acknowledge affordance: without it a status update offers a text box and a dismiss,
11465
+ * and neither of those is "got it" (owner, 2026-08-10). */
11466
+ asks: external_exports.boolean().optional(),
11467
+ /** When a live process last pulsed for this row's agent — the liveness input for
11468
+ * "working requires a pulse" (#928): the list said "Working…" from agent_state alone
11469
+ * while the party called the same dead claim stalled. Absent = no token/no data,
11470
+ * which must never CLAIM stalled. */
11471
+ lastSeenAt: external_exports.string().optional(),
11389
11472
  agenda: external_exports.array(AgendaTurnSchema).optional(),
11390
11473
  /** On a replied detail (#397): the next steps the user attached to the answer
11391
11474
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -11410,6 +11493,23 @@ var InboxItemSchema = external_exports.object({
11410
11493
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
11411
11494
  error: external_exports.string().optional(),
11412
11495
  clarifies: external_exports.string().optional(),
11496
+ /** The ring ladder ran out while this was still pending — we tried to reach you and
11497
+ * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
11498
+ * agent with nothing to say, which the roster drew identically until now: "nothing to
11499
+ * say" and "gave up saying it" are opposite situations wearing the same face
11500
+ * (navigation-design.md, gap 1). False for anything that never rang. */
11501
+ gaveUp: external_exports.boolean().default(false),
11502
+ /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
11503
+ * Absent for anything never delivered through a push, and for older rows written before
11504
+ * the reason was recorded. Deliberately a debug affordance, shown small (owner,
11505
+ * 2026-08-07) — its real job is to give "this didn't need a call" something to be
11506
+ * feedback ABOUT. */
11507
+ why: external_exports.object({
11508
+ asked: NotifyLevelSchema,
11509
+ got: NotifyLevelSchema,
11510
+ because: external_exports.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
11511
+ line: external_exports.string()
11512
+ }).optional(),
11413
11513
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
11414
11514
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
11415
11515
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -11531,6 +11631,14 @@ var ConnectionSummarySchema = external_exports.object({
11531
11631
  provider: external_exports.string().nullable(),
11532
11632
  /** The pairing's assigned voice (#462); null = the default voice. */
11533
11633
  voice: VoiceKeySchema.nullable(),
11634
+ /** The LOUDEST this agent may ever reach you — a ceiling on `NOTIFY_LADDER`, set by the
11635
+ * user on the agent's own page. null = no ceiling (today's behaviour for every
11636
+ * connection). Android binds importance to a relationship rather than to each message,
11637
+ * and that is the thing our roster could not say: "Marlow may always call me; Otto
11638
+ * never may" (navigation-design.md, gap 2). Clamped in `arbitrateLevel`, so it binds
11639
+ * every surface at once and outranks even `sessionMode: all_calls` — a mode the user
11640
+ * set once must not overrule a rule they set about one agent. */
11641
+ reach: NotifyLevelSchema.nullable().optional(),
11534
11642
  createdAt: external_exports.string().datetime(),
11535
11643
  /** Most recent notification on this connection, either direction. Null = no contact yet.
11536
11644
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -11549,6 +11657,40 @@ var ConnectionSummarySchema = external_exports.object({
11549
11657
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
11550
11658
  managed: external_exports.boolean()
11551
11659
  });
11660
+ var MoveRingSchema = external_exports.enum(["home", "travels", "retired", "quarantined"]);
11661
+ var MoveSchema = external_exports.object({
11662
+ id: external_exports.string(),
11663
+ /** The reusable question, as distill normalized it. */
11664
+ question: external_exports.string(),
11665
+ /** The operative ruling. Editable by the user (PATCH) — which resets the ledger. */
11666
+ answer: external_exports.string(),
11667
+ /** The user's stated reason, when they gave one. Null = inherently narrow: the judge is
11668
+ * told so, and the ruling only derives essentially the same question in the same scope. */
11669
+ rationale: external_exports.string().nullable(),
11670
+ /** Where the ruling lives: a repo/workspace, or 'global'. */
11671
+ scope: external_exports.string(),
11672
+ ring: MoveRingSchema,
11673
+ /** True = the user pinned it with `always` (travel granted by hand, not by evidence). */
11674
+ pinned: external_exports.boolean(),
11675
+ /** True = a pin the user placed was BROKEN by later counter-evidence. Surfaced so the
11676
+ * break is visible instead of a pin silently disappearing. */
11677
+ pinBroken: external_exports.boolean(),
11678
+ /** When the ruling was distilled. */
11679
+ learnedAt: external_exports.string(),
11680
+ /** Last time it answered an ask. Null = never fired. */
11681
+ lastUsedAt: external_exports.string().nullable(),
11682
+ /** How many asks it has answered. Instrumentation — deliberately NOT an input to the
11683
+ * evidence curve: firing says the question keeps arising, not that the ruling is right. */
11684
+ usedCount: external_exports.number(),
11685
+ /** Ledger: outcomes that said it held up. Saturating — the tenth is worth almost nothing. */
11686
+ confirms: external_exports.number(),
11687
+ /** Ledger: contradictions, in signal units (a full override = 1, weaker signals less).
11688
+ * Linear and priced above the entire confirmation budget, so any full counter wins. */
11689
+ counters: external_exports.number(),
11690
+ /** The agent that asked the question this move came from, when known. Null for a move
11691
+ * distilled from a clarify ruling (those carry no agent) or one whose source rows are gone. */
11692
+ learnedFrom: external_exports.object({ id: external_exports.string(), name: external_exports.string() }).nullable()
11693
+ });
11552
11694
  var CreateRequestSchema = external_exports.object({
11553
11695
  /** The connection (token id) to send to, from GET /api/tokens. */
11554
11696
  tokenId: external_exports.string(),
@@ -11755,6 +11897,17 @@ var DeviceTokenSchema = external_exports.object({
11755
11897
  * a default silly name). */
11756
11898
  name: external_exports.string(),
11757
11899
  device: external_exports.string().nullable(),
11900
+ /** The pairing's assigned voice, cached so the desktop can seed the SAME face the phone
11901
+ * draws — voice is the third ingredient of a hatchling's build (party/traits.ts). */
11902
+ voice: external_exports.string().nullable().optional(),
11903
+ /** The token's server-side id — the face's COLOUR anchor, and the only seed ingredient
11904
+ * that survives a rename. Cached by the host's identity beat. */
11905
+ token_id: external_exports.string().nullable().optional(),
11906
+ /** WHERE this identity works — the folder a wake should land it in. Written by the host
11907
+ * at spawn and by `paigy-harness handoff` from a live terminal. Without it every wake
11908
+ * landed in the FIRST granted workspace and the agent rediscovered its own repo from
11909
+ * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
11910
+ workspace: external_exports.string().nullable().optional(),
11758
11911
  phone_reveal: PairingRevealSchema.nullable().optional(),
11759
11912
  // present once the phone reveals
11760
11913
  uik_pub: external_exports.string().nullable().optional()
@@ -11781,6 +11934,8 @@ var NotificationFeedbackKindSchema = external_exports.enum([
11781
11934
  // "There should be a picture or design here."
11782
11935
  "should_have_called",
11783
11936
  // "Don't put this in a banner — ring me for something like this."
11937
+ "should_have_messaged",
11938
+ // the inverse: "that didn't deserve a ring — a message would do."
11784
11939
  "other"
11785
11940
  // anything else — the note carries it.
11786
11941
  ]);
@@ -12077,7 +12232,11 @@ function open(envelope, myKeyId, mySecretKeyB64) {
12077
12232
  if (!eqCt(sealedCanon, expected)) throw new Error("header mismatch (tampered metadata)");
12078
12233
  return { header: envelope.hdr, body };
12079
12234
  }
12080
- var AGENT_NAME = process.env.PAIGY_AGENT ?? "mcp-agent";
12235
+ function sessionSlot(sessionId) {
12236
+ const id = sessionId ?? process.env.PAIGY_SESSION_ID ?? process.env.CLAUDE_CODE_SESSION_ID ?? randomUUID2();
12237
+ return `session:${id.slice(0, 8)}`;
12238
+ }
12239
+ var AGENT_NAME = process.env.PAIGY_AGENT || sessionSlot();
12081
12240
  var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
12082
12241
  var KEY_PATH = join(homedir(), ".paigy", "key.json");
12083
12242
  function readTokenFile() {
@@ -12092,10 +12251,45 @@ function readTokenFile() {
12092
12251
  return {};
12093
12252
  }
12094
12253
  }
12095
- function saveToken(token, agent2 = AGENT_NAME) {
12096
- const slots = { ...readTokenFile(), [agent2]: token };
12254
+ function withTokenLock(mutate) {
12255
+ const lock = TOKEN_PATH + ".lock";
12256
+ const spin = new Int32Array(new SharedArrayBuffer(4));
12097
12257
  mkdirSync(join(homedir(), ".paigy"), { recursive: true });
12098
- writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
12258
+ for (const deadline = Date.now() + 5e3; Date.now() < deadline; ) {
12259
+ try {
12260
+ closeSync(openSync(lock, "wx"));
12261
+ break;
12262
+ } catch {
12263
+ const held = statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now();
12264
+ if (Date.now() - held > 5e3) rmSync(lock, { force: true });
12265
+ else Atomics.wait(spin, 0, 0, 25);
12266
+ }
12267
+ }
12268
+ try {
12269
+ return mutate();
12270
+ } finally {
12271
+ rmSync(lock, { force: true });
12272
+ }
12273
+ }
12274
+ function saveToken(token, agent2 = AGENT_NAME) {
12275
+ withTokenLock(() => {
12276
+ const slots = { ...readTokenFile(), [agent2]: token };
12277
+ writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
12278
+ });
12279
+ }
12280
+ function updateSlot(agent2, patch) {
12281
+ return withTokenLock(() => {
12282
+ const slots = readTokenFile();
12283
+ const existing = slots[agent2];
12284
+ if (!existing) return false;
12285
+ slots[agent2] = { ...existing, ...patch };
12286
+ writeFileSync(TOKEN_PATH, JSON.stringify(slots, null, 2) + "\n", { mode: 384 });
12287
+ return true;
12288
+ });
12289
+ }
12290
+ function slotIdentity(agent2) {
12291
+ const t = readTokenFile()[agent2];
12292
+ return { name: t?.name ?? null, voice: t?.voice ?? null, tokenId: t?.token_id ?? null, workspace: t?.workspace ?? null };
12099
12293
  }
12100
12294
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12101
12295
  function readToken(agent2 = AGENT_NAME) {
@@ -12105,6 +12299,9 @@ function readToken(agent2 = AGENT_NAME) {
12105
12299
  function listSlots() {
12106
12300
  return Object.keys(readTokenFile());
12107
12301
  }
12302
+ function slotName(agent2) {
12303
+ return readTokenFile()[agent2]?.name ?? null;
12304
+ }
12108
12305
  async function requestCode(suggestedName = AGENT_NAME, e2ee) {
12109
12306
  const res = await reach(`${BACKEND_URL}/api/device/code`, {
12110
12307
  method: "POST",
@@ -12259,6 +12456,16 @@ async function checkReplies(opts = {}) {
12259
12456
  }
12260
12457
  var tokenOverride = null;
12261
12458
  var authToken = (explicit) => explicit ?? tokenOverride ?? readToken();
12459
+ async function whoAmI(opts = {}) {
12460
+ try {
12461
+ const res = await reach(`${BACKEND_URL}/api/identity`, {
12462
+ headers: { authorization: `Bearer ${authToken(opts.token)}` }
12463
+ });
12464
+ return res.ok ? await res.json() : null;
12465
+ } catch {
12466
+ return null;
12467
+ }
12468
+ }
12262
12469
  async function claimSessions(opts = {}) {
12263
12470
  const res = ensureAuthed(await reach(`${BACKEND_URL}/api/sessions/claim`, {
12264
12471
  headers: { authorization: `Bearer ${authToken(opts.token)}` }
@@ -12287,7 +12494,7 @@ async function setTaskState(notificationId, state, opts = {}) {
12287
12494
 
12288
12495
  // src/main.ts
12289
12496
  var import_qrcode = __toESM(require_lib(), 1);
12290
- import { hostname } from "os";
12497
+ import { homedir as homedir6, hostname } from "os";
12291
12498
 
12292
12499
  // src/harness/catalog.ts
12293
12500
  import { spawnSync } from "child_process";
@@ -12314,6 +12521,14 @@ var CATALOG = [
12314
12521
  loginHint: "Run `codex login` to authenticate.",
12315
12522
  installCli: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
12316
12523
  installAdapter: "npm install -g @agentclientprotocol/codex-acp"
12524
+ },
12525
+ {
12526
+ name: "agy",
12527
+ label: "Antigravity",
12528
+ cli: "agy",
12529
+ authProbe: ["agy", "help"],
12530
+ loginHint: "Install and set up Antigravity (`agy`).",
12531
+ installCli: "curl -fsSL https://antigravity.dev/install.sh | bash"
12317
12532
  }
12318
12533
  ];
12319
12534
  function probeDirs(home = homedir2()) {
@@ -12369,6 +12584,11 @@ function detectAll(deps = {}) {
12369
12584
  return CATALOG.map((entry) => detect(entry, deps));
12370
12585
  }
12371
12586
 
12587
+ // src/host.ts
12588
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
12589
+ import { homedir as homedir5 } from "os";
12590
+ import { join as join4 } from "path";
12591
+
12372
12592
  // src/workspaces.ts
12373
12593
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
12374
12594
  import { dirname as dirname2, join as join3, resolve } from "path";
@@ -12400,6 +12620,17 @@ function removeWorkspace(dir, deps) {
12400
12620
  writeFileSync2(deps.file, JSON.stringify(all, null, 2));
12401
12621
  return all;
12402
12622
  }
12623
+ function allowed(cwd, deps) {
12624
+ const target = resolve(cwd.replace(/^~(?=$|\/)/, homedir3()));
12625
+ return listWorkspaces(deps).some((w) => target === w || target.startsWith(`${w}/`));
12626
+ }
12627
+ function resolveWakeDir(pinned, deps) {
12628
+ if (pinned && allowed(pinned, deps)) {
12629
+ const dir = resolve(pinned.replace(/^~(?=$|\/)/, homedir3()));
12630
+ if ((deps.exists ?? existsSync3)(dir)) return dir;
12631
+ }
12632
+ return listWorkspaces(deps)[0];
12633
+ }
12403
12634
 
12404
12635
  // src/harness/session.ts
12405
12636
  import { spawn } from "child_process";
@@ -12433,6 +12664,14 @@ var AcpDriver = class {
12433
12664
  queued = [];
12434
12665
  /** Options of each unanswered permission request, keyed by its JSON-RPC id. */
12435
12666
  pending = /* @__PURE__ */ new Map();
12667
+ /** Where the REPORT starts in `text` — everything before the LAST tool call is working
12668
+ * narration ("Now the API endpoints." → runs a tool), and it used to ship: the chunks
12669
+ * concatenate with no separator, so the owner's phone got "…find the repo.Now I have
12670
+ * the full picture. Writing the migration.Now…" as the opening paragraph of a finished
12671
+ * task (live, 2026-08-11 — "looks like a working log"). The narration's audience is the
12672
+ * terminal and host.log; what the agent composed AFTER its last tool call is the part
12673
+ * addressed to a human, and that is what leaves the machine. */
12674
+ reportFrom = 0;
12436
12675
  /** The turn being streamed: text accumulates, tools append, both flush on stopReason. */
12437
12676
  text = "";
12438
12677
  tools = [];
@@ -12528,18 +12767,20 @@ var AcpDriver = class {
12528
12767
  if (msg.id === this.promptId) {
12529
12768
  this.promptId = void 0;
12530
12769
  const events = [];
12531
- if (this.text.trim() || this.tools.length) {
12770
+ const report = this.text.slice(this.reportFrom).trim() || this.text.trim();
12771
+ if (report || this.tools.length) {
12532
12772
  events.push({
12533
12773
  kind: "turn",
12534
12774
  role: "agent",
12535
- text: this.text.trim(),
12775
+ text: report,
12536
12776
  ...this.tools.length ? { tools: [...this.tools] } : {}
12537
12777
  });
12538
12778
  }
12539
- const result = this.text.trim();
12779
+ const result = report;
12540
12780
  const failed = msg.error !== void 0 || msg.result?.stopReason === "refusal";
12541
12781
  this.text = "";
12542
12782
  this.tools = [];
12783
+ this.reportFrom = 0;
12543
12784
  const writes = this.flush();
12544
12785
  if (!writes.length) {
12545
12786
  events.push({
@@ -12563,7 +12804,9 @@ var AcpDriver = class {
12563
12804
  case "tool_call": {
12564
12805
  const title = update.title?.trim() || update.kind || "tool";
12565
12806
  this.tools.push(title);
12566
- return none;
12807
+ const note = this.text.slice(this.reportFrom).trim();
12808
+ this.reportFrom = this.text.length;
12809
+ return { events: [{ kind: "work", tool: title, ...note ? { note } : {} }], writes: [] };
12567
12810
  }
12568
12811
  default:
12569
12812
  return none;
@@ -12617,7 +12860,8 @@ var frame = (body) => JSON.stringify({ jsonrpc: "2.0", ...body });
12617
12860
  // src/harness/session.ts
12618
12861
  var ADAPTER_BIN = {
12619
12862
  claude: "claude-agent-acp",
12620
- codex: "codex-acp"
12863
+ codex: "codex-acp",
12864
+ agy: "agy"
12621
12865
  };
12622
12866
  function splitLines(buffer, chunk) {
12623
12867
  const combined = buffer + chunk;
@@ -12688,7 +12932,9 @@ function startSession(opts) {
12688
12932
  // ../../packages/schema/dist/index.js
12689
12933
  var ContextSchema2 = external_exports.object({
12690
12934
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
12691
- 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.")
12935
+ description: external_exports.array(external_exports.string().min(1)).describe(
12936
+ "Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand. MAY BE EMPTY: a claim whose whole content is its heading \u2014 a single sentence \u2014 has no body, and saying so beats repeating the heading underneath itself. That repeat is what `min(1)` used to force, at 2x the storage, with every reader subtracting it back out at render time."
12937
+ )
12692
12938
  });
12693
12939
  var ParticipantSchema2 = external_exports.object({
12694
12940
  kind: external_exports.enum(["human", "agent"]),
@@ -12835,6 +13081,14 @@ var NotifyRequestSchema2 = external_exports.object({
12835
13081
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
12836
13082
  "With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
12837
13083
  ),
13084
+ /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
13085
+ * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
13086
+ * holding by default would charge every quiet claim that minute before any agent could
13087
+ * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
13088
+ * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
13089
+ confirm: external_exports.boolean().optional().describe(
13090
+ "Hold this one so you can correct the plan before the user is interrupted. The response comes back with `held: true` and the plan; POST the confirm route to release it (with options/visuals/urgency corrections, or nothing at all). If you never do, it is announced anyway a couple of minutes later. Ignored when waiting is 'hard'."
13091
+ ),
12838
13092
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
12839
13093
  * choice. Outranks waiting in both directions: 'call' rings even for a
12840
13094
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -12884,6 +13138,23 @@ var NotifyRequestSchema2 = external_exports.object({
12884
13138
  if (!needsOptions && r.options?.length)
12885
13139
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
12886
13140
  });
13141
+ function unitsOf2(text) {
13142
+ const spans = [];
13143
+ const re = /\n\s*\n+/g;
13144
+ let cursor = 0;
13145
+ const push = (from, to) => {
13146
+ const slice = text.slice(from, to);
13147
+ const lead = slice.length - slice.trimStart().length;
13148
+ const tail = slice.length - slice.trimEnd().length;
13149
+ if (from + lead < to - tail) spans.push({ start: from + lead, end: to - tail });
13150
+ };
13151
+ for (let m = re.exec(text); m; m = re.exec(text)) {
13152
+ push(cursor, m.index);
13153
+ cursor = m.index + m[0].length;
13154
+ }
13155
+ push(cursor, text.length);
13156
+ return spans;
13157
+ }
12887
13158
  var NotifyStatusSchema2 = external_exports.enum(["pending", "answered", "ignored"]);
12888
13159
  var AgentStateSchema2 = external_exports.enum(["idle", "in_progress", "completed", "needs_input"]);
12889
13160
  var SetTaskStateSchema2 = external_exports.object({
@@ -13066,6 +13337,34 @@ var NotifyResponseSchema2 = external_exports.object({
13066
13337
  answer: UserAnswerSchema2.optional(),
13067
13338
  answeredAt: external_exports.string().datetime().optional()
13068
13339
  });
13340
+ var NotifyPlanUnitSchema2 = external_exports.object({
13341
+ notificationId: external_exports.string(),
13342
+ /** The unit's own heading, so the agent can tell which of its paragraphs this became. */
13343
+ title: external_exports.string(),
13344
+ /** How loudly this unit was arbitrated to arrive — per unit, which is the point of units. */
13345
+ level: NotifyLevelSchema2,
13346
+ /** Answered from something the user already decided: nobody is interrupted, and a trail card
13347
+ * says so. The agent should not wait on this one. */
13348
+ settled: external_exports.literal(true).optional(),
13349
+ /** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
13350
+ * agent — nothing here changed the ask, and ignoring it costs nothing. */
13351
+ needs: external_exports.array(external_exports.enum(["options", "visuals"])).optional(),
13352
+ /** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
13353
+ * split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
13354
+ * broker that DECIDES that destroys the only fact separating a statement from a real ask
13355
+ * (#731), so it is offered, never applied: the unit is stored `text` until the agent
13356
+ * confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
13357
+ proposal: external_exports.object({
13358
+ select: SelectShapeSchema2,
13359
+ options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
13360
+ }).optional()
13361
+ });
13362
+ var NotifyPlanSchema2 = external_exports.object({
13363
+ units: external_exports.array(NotifyPlanUnitSchema2),
13364
+ /** Which unit is this arrival's ONE interruption (units-design.md D23). Absent means nobody
13365
+ * was interrupted — every unit was either settled or quiet enough to sit in the inbox. */
13366
+ speaks: external_exports.string().optional()
13367
+ });
13069
13368
  var UserResponseSchema2 = external_exports.object({
13070
13369
  requestId: external_exports.string(),
13071
13370
  answer: UserAnswerSchema2,
@@ -13120,6 +13419,20 @@ var InboxItemSchema2 = external_exports.object({
13120
13419
  /** The conversation thread + connection this item lives on. Present on the replied
13121
13420
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
13122
13421
  parentId: external_exports.string().optional(),
13422
+ /** THE ARRIVAL this row is one unit of (`notifications.ask_id` → `asks`). A claim is one
13423
+ * arrival and its units are N rows of it, so this — not `parentId` — is what makes a
13424
+ * multi-part notification one thing on screen. The thread is the whole CONVERSATION: it
13425
+ * accumulates every message an agent ever sent, so grouping by it renders a day of
13426
+ * unrelated updates as a single "12-part request". Absent on rows written before the
13427
+ * `asks` table, and on anything that never went through `notify` — both fall back to the
13428
+ * thread, which is what the client did for all rows until now. */
13429
+ askId: external_exports.string().optional(),
13430
+ /** WHERE this unit sat in the message it was cut from (`notifications.seq`). The batch
13431
+ * shares one `created_at` to the microsecond, so without it the author's order is
13432
+ * unrecoverable client-side — a four-paragraph briefing rendered opening-paragraph-last
13433
+ * (live 2026-08-10, D35). The API already orders by it; this lets a reader that
13434
+ * re-sorts (grouping, filtering) put an arrival back in the order it was written. */
13435
+ seq: external_exports.number().int().optional(),
13123
13436
  tokenId: external_exports.string().optional(),
13124
13437
  status: NotifyStatusSchema2,
13125
13438
  context: ContextSchema2,
@@ -13130,6 +13443,16 @@ var InboxItemSchema2 = external_exports.object({
13130
13443
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
13131
13444
  * between them could not express a call as a sequence. `question: null` is a real turn —
13132
13445
  * a status update stays a statement instead of being shaped into a yes/no. */
13446
+ /** Does this claim want an ANSWER, or is it telling you something? Written per row from
13447
+ * `requestAsks` — the agent's own declaration, not a guess. `false` is what earns a card
13448
+ * its acknowledge affordance: without it a status update offers a text box and a dismiss,
13449
+ * and neither of those is "got it" (owner, 2026-08-10). */
13450
+ asks: external_exports.boolean().optional(),
13451
+ /** When a live process last pulsed for this row's agent — the liveness input for
13452
+ * "working requires a pulse" (#928): the list said "Working…" from agent_state alone
13453
+ * while the party called the same dead claim stalled. Absent = no token/no data,
13454
+ * which must never CLAIM stalled. */
13455
+ lastSeenAt: external_exports.string().optional(),
13133
13456
  agenda: external_exports.array(AgendaTurnSchema2).optional(),
13134
13457
  /** On a replied detail (#397): the next steps the user attached to the answer
13135
13458
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -13154,6 +13477,23 @@ var InboxItemSchema2 = external_exports.object({
13154
13477
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
13155
13478
  error: external_exports.string().optional(),
13156
13479
  clarifies: external_exports.string().optional(),
13480
+ /** The ring ladder ran out while this was still pending — we tried to reach you and
13481
+ * STOPPED trying (`arbitration/arbitrate.ts` `nextRing` → `stop`). Distinct from an
13482
+ * agent with nothing to say, which the roster drew identically until now: "nothing to
13483
+ * say" and "gave up saying it" are opposite situations wearing the same face
13484
+ * (navigation-design.md, gap 1). False for anything that never rang. */
13485
+ gaveUp: external_exports.boolean().default(false),
13486
+ /** Why this arrived the way it did, read back off the delivery receipt (`notify/why.ts`).
13487
+ * Absent for anything never delivered through a push, and for older rows written before
13488
+ * the reason was recorded. Deliberately a debug affordance, shown small (owner,
13489
+ * 2026-08-07) — its real job is to give "this didn't need a call" something to be
13490
+ * feedback ABOUT. */
13491
+ why: external_exports.object({
13492
+ asked: NotifyLevelSchema2,
13493
+ got: NotifyLevelSchema2,
13494
+ because: external_exports.enum(["unresponsive", "dismissed", "not_permitted", "silent", "coalesced", "agent_capped", "unplanned", "learned_raise"]).optional(),
13495
+ line: external_exports.string()
13496
+ }).optional(),
13157
13497
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
13158
13498
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
13159
13499
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -13275,6 +13615,14 @@ var ConnectionSummarySchema2 = external_exports.object({
13275
13615
  provider: external_exports.string().nullable(),
13276
13616
  /** The pairing's assigned voice (#462); null = the default voice. */
13277
13617
  voice: VoiceKeySchema2.nullable(),
13618
+ /** The LOUDEST this agent may ever reach you — a ceiling on `NOTIFY_LADDER`, set by the
13619
+ * user on the agent's own page. null = no ceiling (today's behaviour for every
13620
+ * connection). Android binds importance to a relationship rather than to each message,
13621
+ * and that is the thing our roster could not say: "Marlow may always call me; Otto
13622
+ * never may" (navigation-design.md, gap 2). Clamped in `arbitrateLevel`, so it binds
13623
+ * every surface at once and outranks even `sessionMode: all_calls` — a mode the user
13624
+ * set once must not overrule a rule they set about one agent. */
13625
+ reach: NotifyLevelSchema2.nullable().optional(),
13278
13626
  createdAt: external_exports.string().datetime(),
13279
13627
  /** Most recent notification on this connection, either direction. Null = no contact yet.
13280
13628
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -13293,6 +13641,40 @@ var ConnectionSummarySchema2 = external_exports.object({
13293
13641
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
13294
13642
  managed: external_exports.boolean()
13295
13643
  });
13644
+ var MoveRingSchema2 = external_exports.enum(["home", "travels", "retired", "quarantined"]);
13645
+ var MoveSchema2 = external_exports.object({
13646
+ id: external_exports.string(),
13647
+ /** The reusable question, as distill normalized it. */
13648
+ question: external_exports.string(),
13649
+ /** The operative ruling. Editable by the user (PATCH) — which resets the ledger. */
13650
+ answer: external_exports.string(),
13651
+ /** The user's stated reason, when they gave one. Null = inherently narrow: the judge is
13652
+ * told so, and the ruling only derives essentially the same question in the same scope. */
13653
+ rationale: external_exports.string().nullable(),
13654
+ /** Where the ruling lives: a repo/workspace, or 'global'. */
13655
+ scope: external_exports.string(),
13656
+ ring: MoveRingSchema2,
13657
+ /** True = the user pinned it with `always` (travel granted by hand, not by evidence). */
13658
+ pinned: external_exports.boolean(),
13659
+ /** True = a pin the user placed was BROKEN by later counter-evidence. Surfaced so the
13660
+ * break is visible instead of a pin silently disappearing. */
13661
+ pinBroken: external_exports.boolean(),
13662
+ /** When the ruling was distilled. */
13663
+ learnedAt: external_exports.string(),
13664
+ /** Last time it answered an ask. Null = never fired. */
13665
+ lastUsedAt: external_exports.string().nullable(),
13666
+ /** How many asks it has answered. Instrumentation — deliberately NOT an input to the
13667
+ * evidence curve: firing says the question keeps arising, not that the ruling is right. */
13668
+ usedCount: external_exports.number(),
13669
+ /** Ledger: outcomes that said it held up. Saturating — the tenth is worth almost nothing. */
13670
+ confirms: external_exports.number(),
13671
+ /** Ledger: contradictions, in signal units (a full override = 1, weaker signals less).
13672
+ * Linear and priced above the entire confirmation budget, so any full counter wins. */
13673
+ counters: external_exports.number(),
13674
+ /** The agent that asked the question this move came from, when known. Null for a move
13675
+ * distilled from a clarify ruling (those carry no agent) or one whose source rows are gone. */
13676
+ learnedFrom: external_exports.object({ id: external_exports.string(), name: external_exports.string() }).nullable()
13677
+ });
13296
13678
  var CreateRequestSchema2 = external_exports.object({
13297
13679
  /** The connection (token id) to send to, from GET /api/tokens. */
13298
13680
  tokenId: external_exports.string(),
@@ -13499,6 +13881,17 @@ var DeviceTokenSchema2 = external_exports.object({
13499
13881
  * a default silly name). */
13500
13882
  name: external_exports.string(),
13501
13883
  device: external_exports.string().nullable(),
13884
+ /** The pairing's assigned voice, cached so the desktop can seed the SAME face the phone
13885
+ * draws — voice is the third ingredient of a hatchling's build (party/traits.ts). */
13886
+ voice: external_exports.string().nullable().optional(),
13887
+ /** The token's server-side id — the face's COLOUR anchor, and the only seed ingredient
13888
+ * that survives a rename. Cached by the host's identity beat. */
13889
+ token_id: external_exports.string().nullable().optional(),
13890
+ /** WHERE this identity works — the folder a wake should land it in. Written by the host
13891
+ * at spawn and by `paigy-harness handoff` from a live terminal. Without it every wake
13892
+ * landed in the FIRST granted workspace and the agent rediscovered its own repo from
13893
+ * the thread each time (host.ts, live catch 2026-08-06 — prompt-papered until now). */
13894
+ workspace: external_exports.string().nullable().optional(),
13502
13895
  phone_reveal: PairingRevealSchema2.nullable().optional(),
13503
13896
  // present once the phone reveals
13504
13897
  uik_pub: external_exports.string().nullable().optional()
@@ -13525,6 +13918,8 @@ var NotificationFeedbackKindSchema2 = external_exports.enum([
13525
13918
  // "There should be a picture or design here."
13526
13919
  "should_have_called",
13527
13920
  // "Don't put this in a banner — ring me for something like this."
13921
+ "should_have_messaged",
13922
+ // the inverse: "that didn't deserve a ring — a message would do."
13528
13923
  "other"
13529
13924
  // anything else — the note carries it.
13530
13925
  ]);
@@ -13639,6 +14034,9 @@ function levelFor(event) {
13639
14034
  case "idle":
13640
14035
  if (endsWithQuestion(event.result)) return "banner";
13641
14036
  return event.failed ? "push" : "inbox";
14037
+ case "work":
14038
+ return "inbox";
14039
+ // moot — entryFor drops work before a level ever matters
13642
14040
  case "error":
13643
14041
  return "inbox";
13644
14042
  }
@@ -13659,16 +14057,14 @@ function entryFor(event, state = {}) {
13659
14057
  switch (event.kind) {
13660
14058
  case "turn": {
13661
14059
  const who = event.role === "agent" ? "Agent" : "You";
13662
- const title = event.text ? `${who}: ${firstLine(event.text)}` : `${who} ran ${(event.tools ?? []).join(", ")}`;
13663
- const description = [event.text, event.tools?.length ? `Tools: ${event.tools.join(", ")}` : null].filter((c) => Boolean(c));
14060
+ const body = event.text ? event.role === "agent" ? event.text : `${who}: ${event.text}` : `${who} ran ${(event.tools ?? []).join(", ")}.`;
13664
14061
  return {
13665
14062
  ...common,
13666
- context: { title: clip(title), description: description.length ? description : [title] },
13667
- // The shaped form always carries an answer shape the contract's rule, and the
13668
- // right one: any inbox row can be replied to, and a reply to history is just
13669
- // the user initiating (the pump feeds it back in). Non-blocking, so it never
13670
- // reads as a question.
13671
- select: "text"
14063
+ ask: body
14064
+ // No `select`: the contract refuses it on the `ask` form ("the broker derives it"),
14065
+ // and with no options it derives "text" anyway the shape this wants, since any
14066
+ // inbox row can be replied to and a reply to history is just the user initiating
14067
+ // (the pump feeds it back in).
13672
14068
  };
13673
14069
  }
13674
14070
  case "permission":
@@ -13690,16 +14086,24 @@ function entryFor(event, state = {}) {
13690
14086
  };
13691
14087
  case "idle": {
13692
14088
  const asking = endsWithQuestion(event.result);
14089
+ const result = event.result?.trim();
14090
+ const norm = (t) => (t ?? "").replace(/\s+/g, " ").trim();
14091
+ if (result && !event.failed && norm(result) === norm(state.lastAgentText)) {
14092
+ if (!asking) return null;
14093
+ const spans = unitsOf2(result);
14094
+ const tail = spans.map((sp) => result.slice(sp.start, sp.end)).reverse().find((t) => t.includes("?"));
14095
+ return { ...common, ask: tail ?? result, blocking: true };
14096
+ }
14097
+ const lead = asking ? "" : event.failed ? "The agent stopped without finishing. " : "Turn complete. ";
13693
14098
  return {
13694
14099
  ...common,
13695
- context: {
13696
- title: asking ? clip(firstLine(event.result ?? "")) : event.failed ? "The agent stopped without finishing" : "Turn complete",
13697
- description: [event.result || (event.failed ? "No result reported." : "Done.")]
13698
- },
13699
- select: "text",
14100
+ ask: `${lead}${result || (event.failed ? "No result reported." : "Done.")}`,
13700
14101
  ...asking ? { blocking: true } : {}
13701
14102
  };
13702
14103
  }
14104
+ case "work":
14105
+ return null;
14106
+ // log-only by contract — the live texture of the working log
13703
14107
  case "error":
13704
14108
  return null;
13705
14109
  }
@@ -13721,9 +14125,16 @@ function decisionFrom(answer) {
13721
14125
  }
13722
14126
  async function mirror(event, state, deps = {}) {
13723
14127
  const entry = entryFor(event, state);
14128
+ if (event.kind === "turn" && event.role === "agent" && event.text) state.lastAgentText = event.text;
13724
14129
  if (!entry) return {};
14130
+ let req;
14131
+ try {
14132
+ req = NotifyRequestSchema2.parse(entry);
14133
+ } catch (e) {
14134
+ 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)}`);
14135
+ return {};
14136
+ }
13725
14137
  try {
13726
- const req = NotifyRequestSchema2.parse(entry);
13727
14138
  const { notificationId, parentId } = await (deps.submit ?? submitNotification)(req);
13728
14139
  (state.mine ??= /* @__PURE__ */ new Set()).add(notificationId);
13729
14140
  return { parentId, notificationId };
@@ -13752,8 +14163,9 @@ async function askQuestion(question, state, deps = {}) {
13752
14163
  ...state.parentId ? { parentId: state.parentId } : {},
13753
14164
  ...state.repo ? { repo: state.repo } : {},
13754
14165
  ...state.branch ? { branch: state.branch } : {},
13755
- context: { title: clip(firstLine(question)), description: [question] },
13756
- select: "text",
14166
+ // Prose in see `entryFor`'s `turn` case. An agent's question is the case most likely
14167
+ // to run long, and its title was a clipped prefix of itself.
14168
+ ask: question,
13757
14169
  blocking: true,
13758
14170
  urgency: "banner"
13759
14171
  };
@@ -13788,7 +14200,6 @@ function spokenText(answer) {
13788
14200
  return null;
13789
14201
  }
13790
14202
  }
13791
- var firstLine = (s) => s.split("\n")[0] ?? s;
13792
14203
  var clip = (s) => (s.length > 120 ? `${s.slice(0, 117)}\u2026` : s) || "(no text)";
13793
14204
 
13794
14205
  // src/run.ts
@@ -13821,6 +14232,10 @@ function runHarness(opts) {
13821
14232
  opts.log(decision.allow ? `\u2713 approved: ${event.summary}` : `\u2717 denied: ${event.summary}`);
13822
14233
  return;
13823
14234
  }
14235
+ if (event.kind === "work") {
14236
+ opts.log(`\u2699 ${event.tool}${event.note ? ` \u2014 ${event.note.split("\n")[0] ?? ""}` : ""}`);
14237
+ return;
14238
+ }
13824
14239
  const { parentId } = await mirror(event, state, deps);
13825
14240
  state.parentId ??= parentId;
13826
14241
  if (event.kind === "turn") opts.log(`${event.role}: ${event.text.split("\n")[0] ?? ""}`);
@@ -13862,6 +14277,7 @@ function runHarness(opts) {
13862
14277
  send(text) {
13863
14278
  session?.send(text);
13864
14279
  },
14280
+ working: () => running && state.resting !== true,
13865
14281
  stop() {
13866
14282
  running = false;
13867
14283
  cancelAsks(state);
@@ -13873,10 +14289,44 @@ function runHarness(opts) {
13873
14289
  }
13874
14290
 
13875
14291
  // src/host.ts
14292
+ var HOST_FILE = join4(homedir5(), ".paigy", "host.json");
14293
+ function readHostState() {
14294
+ try {
14295
+ const s = JSON.parse(readFileSync3(HOST_FILE, "utf8"));
14296
+ if (Date.now() - s.at > 2e4) return null;
14297
+ try {
14298
+ process.kill(s.pid, 0);
14299
+ } catch {
14300
+ return null;
14301
+ }
14302
+ return s;
14303
+ } catch {
14304
+ return null;
14305
+ }
14306
+ }
14307
+ function hostElsewhere() {
14308
+ const s = readHostState();
14309
+ return !!s && s.pid !== process.pid;
14310
+ }
13876
14311
  function startHost(opts) {
13877
14312
  const runs = /* @__PURE__ */ new Map();
13878
14313
  const asHost = { token: opts.token };
14314
+ const refreshIdentities = async () => {
14315
+ for (const slot of listSlots()) {
14316
+ const token = readToken(slot);
14317
+ if (!token) continue;
14318
+ const me = await whoAmI({ token }).catch(() => null);
14319
+ if (!me) continue;
14320
+ const have = slotIdentity(slot);
14321
+ if (me.name !== have.name || (me.voice ?? null) !== have.voice || (me.tokenId ?? null) !== have.tokenId) {
14322
+ updateSlot(slot, { ...me.name ? { name: me.name } : {}, voice: me.voice ?? null, token_id: me.tokenId ?? null });
14323
+ }
14324
+ }
14325
+ };
13879
14326
  const beat = () => {
14327
+ void refreshIdentities();
14328
+ for (const r of runs.values()) if (r.token) void heartbeat(void 0, { token: r.token }).catch(() => {
14329
+ });
13880
14330
  void heartbeat({
13881
14331
  harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status })),
13882
14332
  workspaces: listWorkspaces(opts.wsDeps)
@@ -13901,28 +14351,32 @@ function startHost(opts) {
13901
14351
  },
13902
14352
  log: log2
13903
14353
  });
13904
- runs.set(spec.sessionId, { run, label });
14354
+ runs.set(spec.sessionId, { run, label, token: spec.token });
14355
+ void heartbeat(void 0, { token: spec.token }).catch(() => {
14356
+ });
14357
+ void refreshIdentities();
13905
14358
  try {
13906
- saveToken({ access_token: spec.token, name: label, device: null }, `session:${spec.sessionId.slice(0, 8)}`);
14359
+ saveToken({ access_token: spec.token, name: label, device: null, workspace: spec.workspace }, sessionSlot(spec.sessionId));
13907
14360
  } catch {
13908
14361
  }
13909
14362
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
13910
14363
  }
13911
14364
  }
13912
- const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex" };
13913
- const WAKE_PROMPT = "You were woken because Paigy work is waiting for you. Call check_replies now and handle everything it returns, then follow your paigy instructions to stay in the loop.";
14365
+ const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex", antigravity: "agy" };
14366
+ const WAKE_PROMPT = "You were woken because Paigy work is waiting for you. This is a fresh session with your existing identity, so you may already have work in flight that you can't remember. Call check_replies FIRST, then get_thread on each conversation it returns and read it before you touch anything: it holds what you were doing, where (a repo or worktree may not be this folder), and what the owner already decided. Then handle what's waiting and follow your paigy instructions to stay in the loop. Speak to the owner at exactly TWO moments: when you are BLOCKED on a decision only they can make (contact with waiting:'hard', the decision as the ask, options if you have real ones), and when you are DONE (one short report: what shipped, how you verified it, anything you flagged). Progress is never a contact \u2014 call set_task_state('in_progress') when you pick work up and the app shows you working; narrate to the terminal, not to the human.";
13914
14367
  async function sweepSlots() {
13915
- const workspace = listWorkspaces(opts.wsDeps)[0];
13916
- if (!workspace) return;
13917
14368
  for (const slot of listSlots()) {
13918
14369
  const harness = SLOT_HARNESS[slot] ?? (slot === "Desktop" ? void 0 : "claude");
13919
14370
  const key = `slot:${slot}`;
13920
14371
  if (!harness || runs.has(key)) continue;
13921
14372
  const token = readToken(slot);
13922
14373
  if (!token) continue;
14374
+ const workspace = resolveWakeDir(slotIdentity(slot).workspace, opts.wsDeps);
14375
+ if (!workspace) continue;
13923
14376
  const work = await checkReplies({ token }).catch(() => null);
13924
14377
  if (!work || work.requests.length === 0 && work.replies.length === 0) continue;
13925
- const log2 = (line) => opts.log(`[${slot}] ${line}`);
14378
+ const label = slotName(slot) ?? slot;
14379
+ const log2 = (line) => opts.log(`[${label}] ${line}`);
13926
14380
  const run = runHarness({
13927
14381
  harness,
13928
14382
  cwd: workspace,
@@ -13933,19 +14387,28 @@ function startHost(opts) {
13933
14387
  // A dead run must not squat the slot — evict so the next wake can respawn.
13934
14388
  onExit: () => {
13935
14389
  runs.delete(key);
13936
- opts.log(`\u2716 ${slot} ended`);
14390
+ opts.log(`\u2716 ${label} ended`);
13937
14391
  },
13938
14392
  log: log2
13939
14393
  });
13940
- runs.set(key, { run, label: slot });
13941
- opts.log(`\u25B6 woke ${slot} (${harness}) \u2014 work was waiting in ${workspace}`);
14394
+ runs.set(key, { run, label, token });
14395
+ void heartbeat(void 0, { token }).catch(() => {
14396
+ });
14397
+ opts.log(`\u25B6 woke ${label} (${harness}) \u2014 work was waiting in ${workspace}`);
13942
14398
  }
13943
14399
  }
14400
+ const publish = () => {
14401
+ try {
14402
+ writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: Date.now(), roster: api.roster() }));
14403
+ } catch {
14404
+ }
14405
+ };
13944
14406
  beat();
13945
14407
  const pulse = setInterval(beat, 6e4);
13946
14408
  const spawnPoll = setInterval(() => {
13947
14409
  void claimAndSpawn();
13948
14410
  void sweepSlots();
14411
+ publish();
13949
14412
  }, 5e3);
13950
14413
  const stopSessions = () => {
13951
14414
  for (const { run, label } of runs.values()) {
@@ -13954,42 +14417,77 @@ function startHost(opts) {
13954
14417
  }
13955
14418
  runs.clear();
13956
14419
  };
13957
- return {
14420
+ const api = {
13958
14421
  sessions: () => [...runs.entries()].map(([id, r]) => [id, r.label]),
14422
+ // Every identity this machine holds, plus what it's doing right now. Slots are the
14423
+ // roster (hatched agents, the terminal tools, claimed sessions); a run makes one of
14424
+ // them live, and a run mid-turn makes it working.
14425
+ roster: () => {
14426
+ const live = new Map([...runs.values()].map((r) => [r.label, r.run]));
14427
+ const names = /* @__PURE__ */ new Map();
14428
+ for (const key of listSlots()) if (key !== "Desktop") names.set(slotName(key) ?? key, key);
14429
+ for (const label of live.keys()) if (!names.has(label)) names.set(label, null);
14430
+ return [...names.entries()].map(([name, slot]) => {
14431
+ const id = slot ? slotIdentity(slot) : { voice: null, tokenId: null };
14432
+ return {
14433
+ name,
14434
+ // The slot key rides along so the window's agent page can speak AS this agent —
14435
+ // its pending reads (`checkReplies`, the same pure read the sweep does) need the
14436
+ // slot's own token, and the display name is not a key.
14437
+ slot,
14438
+ tokenId: id.tokenId,
14439
+ voice: id.voice,
14440
+ running: live.has(name),
14441
+ working: live.get(name)?.working() ?? false
14442
+ };
14443
+ });
14444
+ },
13959
14445
  stopSessions,
13960
14446
  stop() {
13961
14447
  clearInterval(pulse);
13962
14448
  clearInterval(spawnPoll);
13963
14449
  stopSessions();
14450
+ try {
14451
+ writeFileSync3(HOST_FILE, JSON.stringify({ pid: process.pid, at: 0, roster: [] }));
14452
+ } catch {
14453
+ }
13964
14454
  }
13965
14455
  };
14456
+ publish();
14457
+ return api;
13966
14458
  }
13967
14459
 
13968
14460
  // src/main.ts
13969
14461
  var here = dirname4(fileURLToPath(import.meta.url));
13970
14462
  var wsDeps = { file: workspacesFile() };
13971
14463
  var DEVICE_SLOT = "Desktop";
13972
- var deviceToken = () => readToken(DEVICE_SLOT) || readToken();
14464
+ var deviceToken = () => readToken(DEVICE_SLOT);
13973
14465
  var win = null;
13974
14466
  function log(message) {
13975
14467
  win?.webContents.send("paigy:log", message);
13976
14468
  }
13977
- var ICON = join4(here, "..", "assets", "icon.png");
14469
+ var ICON = join5(here, "..", "assets", "icon.png");
13978
14470
  function createWindow() {
13979
14471
  win = new BrowserWindow({
13980
- width: 720,
13981
- height: 640,
14472
+ // Sized for the working log, which is now the window's centerpiece; the layout is a
14473
+ // single fluid column (max-width 1080, logs wrap at any token), so any size between
14474
+ // the min and a full screen holds without a horizontal scrollbar.
14475
+ width: 880,
14476
+ height: 820,
14477
+ minWidth: 560,
14478
+ minHeight: 520,
13982
14479
  icon: ICON,
13983
14480
  // Windows/Linux window icon; macOS uses the dock icon below
13984
- webPreferences: { preload: join4(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
14481
+ webPreferences: { preload: join5(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
13985
14482
  });
13986
- void win.loadFile(join4(here, "..", "renderer", "index.html"));
14483
+ void win.loadFile(join5(here, "..", "renderer", "index.html"));
13987
14484
  }
13988
14485
  var host = null;
13989
14486
  app.whenReady().then(() => {
13990
14487
  if (process.platform === "darwin") app.dock?.setIcon(ICON);
13991
14488
  createWindow();
13992
- if (deviceToken()) host = startHost({ wsDeps, token: deviceToken(), log });
14489
+ if (deviceToken() && !hostElsewhere()) host = startHost({ wsDeps, token: deviceToken(), log });
14490
+ else if (hostElsewhere()) log("\u25C9 this Mac is already hosting (the background service) \u2014 showing its agents");
13993
14491
  app.on("activate", () => {
13994
14492
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
13995
14493
  });
@@ -14007,7 +14505,10 @@ ipcMain.handle("paigy:status", () => ({
14007
14505
  // (works, but the beacon upgrades it); none = show the beacon.
14008
14506
  paired: Boolean(deviceToken()),
14009
14507
  device: Boolean(readToken(DEVICE_SLOT)),
14010
- sessions: host?.sessions().map(([, label]) => label) ?? []
14508
+ sessions: host?.sessions().map(([, label]) => label) ?? [],
14509
+ // The party (companion.md / party design.md): who lives on this machine and what
14510
+ // each one is doing THIS SECOND — the one thing the laptop knows better than the phone.
14511
+ roster: host?.roster() ?? readHostState()?.roster ?? []
14011
14512
  }));
14012
14513
  var beaconLive = false;
14013
14514
  ipcMain.handle("paigy:beacon", async () => {
@@ -14022,7 +14523,7 @@ ipcMain.handle("paigy:beacon", async () => {
14022
14523
  saveToken(token, DEVICE_SLOT);
14023
14524
  beaconLive = false;
14024
14525
  host?.stop();
14025
- host = startHost({ wsDeps, token: deviceToken(), log });
14526
+ if (!hostElsewhere()) host = startHost({ wsDeps, token: deviceToken(), log });
14026
14527
  win?.webContents.send("paigy:paired", { name: token.name });
14027
14528
  log(`\u2713 paired as "${token.name}" \u2014 this machine is now launchable from your phone`);
14028
14529
  return;
@@ -14032,6 +14533,31 @@ ipcMain.handle("paigy:beacon", async () => {
14032
14533
  })();
14033
14534
  return { qr, code: code.user_code };
14034
14535
  });
14536
+ ipcMain.handle("paigy:agent", async (_e, name) => {
14537
+ const entry = (host?.roster() ?? readHostState()?.roster ?? []).find((a) => a.name === name);
14538
+ if (!entry) return null;
14539
+ const token = entry.slot ? readToken(entry.slot) : "";
14540
+ const pending = token ? await checkReplies({ token }).catch(() => null) : null;
14541
+ return {
14542
+ ...entry,
14543
+ pending: pending ? {
14544
+ requests: pending.requests.slice(0, 5).map((r) => ({
14545
+ line: (r.text.split("\n").find(Boolean) ?? "").slice(0, 140),
14546
+ at: r.createdAt
14547
+ })),
14548
+ replies: pending.replies.length
14549
+ } : null
14550
+ };
14551
+ });
14552
+ ipcMain.handle("paigy:agent-log", (_e, name) => {
14553
+ try {
14554
+ const all = readFileSync4(join5(homedir6(), ".paigy", "host.log"), "utf8").split("\n");
14555
+ const mine = all.filter((l) => l.includes(`[${name}]`) || /[▶✖◉⚠]/.test(l) && l.includes(name));
14556
+ return mine.slice(-500).join("\n");
14557
+ } catch {
14558
+ return "";
14559
+ }
14560
+ });
14035
14561
  ipcMain.handle("paigy:workspaces", () => listWorkspaces(wsDeps));
14036
14562
  ipcMain.handle("paigy:workspace-add", async () => {
14037
14563
  const picked = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });