@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/main.js CHANGED
@@ -4577,11 +4577,13 @@ var require_lib = __commonJS({
4577
4577
  // src/main.ts
4578
4578
  import { app, BrowserWindow, dialog, ipcMain } from "electron";
4579
4579
  import { spawn as spawn2 } from "child_process";
4580
+ import { readFileSync as readFileSync4 } from "fs";
4580
4581
  import { fileURLToPath } from "url";
4581
4582
  import { dirname as dirname4, join as join5 } from "path";
4582
4583
 
4583
4584
  // ../../packages/sdk/dist/index.js
4584
4585
  import { createRequire as __sdkCreateRequire } from "module";
4586
+ import { randomUUID } from "crypto";
4585
4587
 
4586
4588
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
4587
4589
  var external_exports = {};
@@ -8625,7 +8627,8 @@ var coerce = {
8625
8627
  var NEVER = INVALID;
8626
8628
 
8627
8629
  // ../../packages/sdk/dist/index.js
8628
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
8630
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
8631
+ import { randomUUID as randomUUID2 } from "crypto";
8629
8632
  import { homedir } from "os";
8630
8633
  import { join } from "path";
8631
8634
  var require2 = __sdkCreateRequire(import.meta.url);
@@ -10891,16 +10894,25 @@ async function proxy() {
10891
10894
  if (!PROXY_ENV.some((k) => process.env[k])) return void 0;
10892
10895
  return agent ??= new (await import("./undici-GCFUZISI.js")).EnvHttpProxyAgent();
10893
10896
  }
10897
+ var INSTANCE_ID = randomUUID();
10898
+ var SESSION_ID = process.env.PAIGY_SESSION_ID ?? process.env.CLAUDE_CODE_SESSION_ID ?? INSTANCE_ID;
10894
10899
  async function reach(url, init) {
10895
10900
  try {
10896
- return await fetch(url, { ...init, dispatcher: await proxy() });
10901
+ const headers = {
10902
+ ...init?.headers,
10903
+ "x-paigy-instance": INSTANCE_ID,
10904
+ "x-paigy-session": SESSION_ID
10905
+ };
10906
+ return await fetch(url, { ...init, headers, dispatcher: await proxy() });
10897
10907
  } catch (e) {
10898
10908
  throw new Error(`${NETWORK_MSG} (${e?.message ?? String(e)})`);
10899
10909
  }
10900
10910
  }
10901
10911
  var ContextSchema = external_exports.object({
10902
10912
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
10903
- description: external_exports.array(external_exports.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
10913
+ description: external_exports.array(external_exports.string().min(1)).describe(
10914
+ "Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand. MAY BE EMPTY: a claim whose whole content is its heading \u2014 a single sentence \u2014 has no body, and saying so beats repeating the heading underneath itself. That repeat is what `min(1)` used to force, at 2x the storage, with every reader subtracting it back out at render time."
10915
+ )
10904
10916
  });
10905
10917
  var ParticipantSchema = external_exports.object({
10906
10918
  kind: external_exports.enum(["human", "agent"]),
@@ -11047,6 +11059,14 @@ var NotifyRequestSchema = external_exports.object({
11047
11059
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
11048
11060
  "With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
11049
11061
  ),
11062
+ /** Δ9b (#895): HOLD this claim so the sender can correct the plan before anyone is
11063
+ * interrupted. Opt-in per claim, because the fail-open is a minute-granularity cron —
11064
+ * holding by default would charge every quiet claim that minute before any agent could
11065
+ * correct anything. Ignored for `waiting: 'hard'`: a blocking ask rings on what we have,
11066
+ * and the enrichment can still land mid-call (#781 re-plans the unspoken tail). */
11067
+ confirm: external_exports.boolean().optional().describe(
11068
+ "Hold this one so you can correct the plan before the user is interrupted. The response comes back with `held: true` and the plan; POST the confirm route to release it (with options/visuals/urgency corrections, or nothing at all). If you never do, it is announced anyway a couple of minutes later. Ignored when waiting is 'hard'."
11069
+ ),
11050
11070
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
11051
11071
  * choice. Outranks waiting in both directions: 'call' rings even for a
11052
11072
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -11105,35 +11125,45 @@ function normalizeWaiting(req) {
11105
11125
  blocking: req.blocking || waiting === "hard"
11106
11126
  };
11107
11127
  }
11108
- var DERIVE_CHUNKS_MAX = 8;
11109
- var DERIVE_CHUNK_MAX = 300;
11110
- function chunkAsk(text, max = DERIVE_CHUNK_MAX, cap = DERIVE_CHUNKS_MAX) {
11128
+ function unitsOf(text) {
11129
+ const spans = [];
11130
+ const re = /\n\s*\n+/g;
11131
+ let cursor = 0;
11132
+ const push = (from, to) => {
11133
+ const slice = text.slice(from, to);
11134
+ const lead = slice.length - slice.trimStart().length;
11135
+ const tail = slice.length - slice.trimEnd().length;
11136
+ if (from + lead < to - tail) spans.push({ start: from + lead, end: to - tail });
11137
+ };
11138
+ for (let m = re.exec(text); m; m = re.exec(text)) {
11139
+ push(cursor, m.index);
11140
+ cursor = m.index + m[0].length;
11141
+ }
11142
+ push(cursor, text.length);
11143
+ return spans;
11144
+ }
11145
+ function headline(text) {
11146
+ const line = text.split("\n").map((l) => l.trim()).filter((l) => l && !/^`{3,}/.test(l)).map((l) => l.replace(/^(?:#{1,6}|[-*>]|\d{1,3}\.)\s+/, "")).find(Boolean) ?? "";
11147
+ return (/^[^.!?\n]+[.!?]?/.exec(line)?.[0] ?? line).trim();
11148
+ }
11149
+ function bodyAfterHeadline(text) {
11111
11150
  const body = text.trim();
11112
- if (!body) return [];
11113
- const sentences = body.match(/[^.!?]+[.!?]*\s*/g) ?? [body];
11114
- const chunks = [];
11115
- for (const raw of sentences) {
11116
- const s = raw.trim();
11117
- if (!s) continue;
11118
- const last = chunks[chunks.length - 1];
11119
- if (last !== void 0 && `${last} ${s}`.length <= max) chunks[chunks.length - 1] = `${last} ${s}`;
11120
- else chunks.push(s);
11121
- }
11122
- if (chunks.length <= cap) return chunks;
11123
- return [...chunks.slice(0, cap - 1), chunks.slice(cap - 1).join(" ")];
11151
+ const title = headline(body);
11152
+ const rest = body.slice(title.length).replace(/^[\s.!?—–-]+/, "").trim();
11153
+ if (!rest) return { title, description: [] };
11154
+ const chunks = unitsOf(rest).map((u) => rest.slice(u.start, u.end)).filter(Boolean);
11155
+ return { title, description: chunks.length ? chunks : [rest] };
11124
11156
  }
11125
11157
  function deriveAsk(req) {
11126
11158
  req = normalizeWaiting(req);
11127
11159
  if (!req.ask) return req;
11128
11160
  const text = req.ask.trim();
11129
- const firstSentence = (/^[^.!?\n]+[.!?]?/.exec(text)?.[0] ?? text).trim();
11130
- const title = firstSentence.length > 90 ? `${firstSentence.slice(0, 87).trimEnd()}\u2026` : firstSentence;
11131
11161
  const hinted = req.urgencyHint === "now" ? "call" : req.urgencyHint === "soon" ? "banner" : req.urgencyHint === "whenever" ? "inbox" : req.urgency;
11132
11162
  const urgency = req.channel === "call" ? "call" : req.channel === "message" && hinted === "call" ? "banner" : hinted;
11133
11163
  const { ask: _ask, needs, urgencyHint: _hint, channel: _channel, ...rest } = req;
11134
11164
  return {
11135
11165
  ...rest,
11136
- context: { title, description: chunkAsk(text) },
11166
+ context: bodyAfterHeadline(text),
11137
11167
  // Options riding alongside the ask (#575: pixels can't be prose) floor to a
11138
11168
  // single pick — the model broker may upgrade to many/rank from the wording.
11139
11169
  select: req.options?.length ? "one" : "text",
@@ -11323,6 +11353,34 @@ var NotifyResponseSchema = external_exports.object({
11323
11353
  answer: UserAnswerSchema.optional(),
11324
11354
  answeredAt: external_exports.string().datetime().optional()
11325
11355
  });
11356
+ var NotifyPlanUnitSchema = external_exports.object({
11357
+ notificationId: external_exports.string(),
11358
+ /** The unit's own heading, so the agent can tell which of its paragraphs this became. */
11359
+ title: external_exports.string(),
11360
+ /** How loudly this unit was arbitrated to arrive — per unit, which is the point of units. */
11361
+ level: NotifyLevelSchema,
11362
+ /** Answered from something the user already decided: nobody is interrupted, and a trail card
11363
+ * says so. The agent should not wait on this one. */
11364
+ settled: external_exports.literal(true).optional(),
11365
+ /** What this unit would need to be answerable and does not carry (#894). A PROPOSAL to the
11366
+ * agent — nothing here changed the ask, and ignoring it costs nothing. */
11367
+ needs: external_exports.array(external_exports.enum(["options", "visuals"])).optional(),
11368
+ /** The SHAPE the broker would give this unit, for the agent to ratify (#886/#894). The
11369
+ * split layer reads prose and can see that a paragraph is a yes/no or a pick-one — but a
11370
+ * broker that DECIDES that destroys the only fact separating a statement from a real ask
11371
+ * (#731), so it is offered, never applied: the unit is stored `text` until the agent
11372
+ * confirms the shape (POST /notify/:id/confirm). Ignoring it costs nothing. */
11373
+ proposal: external_exports.object({
11374
+ select: SelectShapeSchema,
11375
+ options: external_exports.array(external_exports.object({ label: external_exports.string().min(1) })).optional()
11376
+ }).optional()
11377
+ });
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
+ });
11326
11384
  var UserResponseSchema = external_exports.object({
11327
11385
  requestId: external_exports.string(),
11328
11386
  answer: UserAnswerSchema,
@@ -11377,6 +11435,20 @@ var InboxItemSchema = external_exports.object({
11377
11435
  /** The conversation thread + connection this item lives on. Present on the replied
11378
11436
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
11379
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(),
11380
11452
  tokenId: external_exports.string().optional(),
11381
11453
  status: NotifyStatusSchema,
11382
11454
  context: ContextSchema,
@@ -11387,6 +11459,16 @@ var InboxItemSchema = external_exports.object({
11387
11459
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
11388
11460
  * between them could not express a call as a sequence. `question: null` is a real turn —
11389
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(),
11390
11472
  agenda: external_exports.array(AgendaTurnSchema).optional(),
11391
11473
  /** On a replied detail (#397): the next steps the user attached to the answer
11392
11474
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -11411,6 +11493,23 @@ var InboxItemSchema = external_exports.object({
11411
11493
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
11412
11494
  error: external_exports.string().optional(),
11413
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(),
11414
11513
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
11415
11514
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
11416
11515
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -11532,6 +11631,14 @@ var ConnectionSummarySchema = external_exports.object({
11532
11631
  provider: external_exports.string().nullable(),
11533
11632
  /** The pairing's assigned voice (#462); null = the default voice. */
11534
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(),
11535
11642
  createdAt: external_exports.string().datetime(),
11536
11643
  /** Most recent notification on this connection, either direction. Null = no contact yet.
11537
11644
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -11550,6 +11657,40 @@ var ConnectionSummarySchema = external_exports.object({
11550
11657
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
11551
11658
  managed: external_exports.boolean()
11552
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
+ });
11553
11694
  var CreateRequestSchema = external_exports.object({
11554
11695
  /** The connection (token id) to send to, from GET /api/tokens. */
11555
11696
  tokenId: external_exports.string(),
@@ -11756,6 +11897,17 @@ var DeviceTokenSchema = external_exports.object({
11756
11897
  * a default silly name). */
11757
11898
  name: external_exports.string(),
11758
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(),
11759
11911
  phone_reveal: PairingRevealSchema.nullable().optional(),
11760
11912
  // present once the phone reveals
11761
11913
  uik_pub: external_exports.string().nullable().optional()
@@ -11782,6 +11934,8 @@ var NotificationFeedbackKindSchema = external_exports.enum([
11782
11934
  // "There should be a picture or design here."
11783
11935
  "should_have_called",
11784
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."
11785
11939
  "other"
11786
11940
  // anything else — the note carries it.
11787
11941
  ]);
@@ -12078,7 +12232,11 @@ function open(envelope, myKeyId, mySecretKeyB64) {
12078
12232
  if (!eqCt(sealedCanon, expected)) throw new Error("header mismatch (tampered metadata)");
12079
12233
  return { header: envelope.hdr, body };
12080
12234
  }
12081
- 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();
12082
12240
  var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
12083
12241
  var KEY_PATH = join(homedir(), ".paigy", "key.json");
12084
12242
  function readTokenFile() {
@@ -12093,10 +12251,45 @@ function readTokenFile() {
12093
12251
  return {};
12094
12252
  }
12095
12253
  }
12096
- function saveToken(token, agent2 = AGENT_NAME) {
12097
- const slots = { ...readTokenFile(), [agent2]: token };
12254
+ function withTokenLock(mutate) {
12255
+ const lock = TOKEN_PATH + ".lock";
12256
+ const spin = new Int32Array(new SharedArrayBuffer(4));
12098
12257
  mkdirSync(join(homedir(), ".paigy"), { recursive: true });
12099
- 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 };
12100
12293
  }
12101
12294
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12102
12295
  function readToken(agent2 = AGENT_NAME) {
@@ -12301,7 +12494,7 @@ async function setTaskState(notificationId, state, opts = {}) {
12301
12494
 
12302
12495
  // src/main.ts
12303
12496
  var import_qrcode = __toESM(require_lib(), 1);
12304
- import { hostname } from "os";
12497
+ import { homedir as homedir6, hostname } from "os";
12305
12498
 
12306
12499
  // src/harness/catalog.ts
12307
12500
  import { spawnSync } from "child_process";
@@ -12328,6 +12521,14 @@ var CATALOG = [
12328
12521
  loginHint: "Run `codex login` to authenticate.",
12329
12522
  installCli: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
12330
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"
12331
12532
  }
12332
12533
  ];
12333
12534
  function probeDirs(home = homedir2()) {
@@ -12419,6 +12620,17 @@ function removeWorkspace(dir, deps) {
12419
12620
  writeFileSync2(deps.file, JSON.stringify(all, null, 2));
12420
12621
  return all;
12421
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
+ }
12422
12634
 
12423
12635
  // src/harness/session.ts
12424
12636
  import { spawn } from "child_process";
@@ -12452,6 +12664,14 @@ var AcpDriver = class {
12452
12664
  queued = [];
12453
12665
  /** Options of each unanswered permission request, keyed by its JSON-RPC id. */
12454
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;
12455
12675
  /** The turn being streamed: text accumulates, tools append, both flush on stopReason. */
12456
12676
  text = "";
12457
12677
  tools = [];
@@ -12547,18 +12767,20 @@ var AcpDriver = class {
12547
12767
  if (msg.id === this.promptId) {
12548
12768
  this.promptId = void 0;
12549
12769
  const events = [];
12550
- 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) {
12551
12772
  events.push({
12552
12773
  kind: "turn",
12553
12774
  role: "agent",
12554
- text: this.text.trim(),
12775
+ text: report,
12555
12776
  ...this.tools.length ? { tools: [...this.tools] } : {}
12556
12777
  });
12557
12778
  }
12558
- const result = this.text.trim();
12779
+ const result = report;
12559
12780
  const failed = msg.error !== void 0 || msg.result?.stopReason === "refusal";
12560
12781
  this.text = "";
12561
12782
  this.tools = [];
12783
+ this.reportFrom = 0;
12562
12784
  const writes = this.flush();
12563
12785
  if (!writes.length) {
12564
12786
  events.push({
@@ -12582,7 +12804,9 @@ var AcpDriver = class {
12582
12804
  case "tool_call": {
12583
12805
  const title = update.title?.trim() || update.kind || "tool";
12584
12806
  this.tools.push(title);
12585
- 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: [] };
12586
12810
  }
12587
12811
  default:
12588
12812
  return none;
@@ -12636,7 +12860,8 @@ var frame = (body) => JSON.stringify({ jsonrpc: "2.0", ...body });
12636
12860
  // src/harness/session.ts
12637
12861
  var ADAPTER_BIN = {
12638
12862
  claude: "claude-agent-acp",
12639
- codex: "codex-acp"
12863
+ codex: "codex-acp",
12864
+ agy: "agy"
12640
12865
  };
12641
12866
  function splitLines(buffer, chunk) {
12642
12867
  const combined = buffer + chunk;
@@ -12707,7 +12932,9 @@ function startSession(opts) {
12707
12932
  // ../../packages/schema/dist/index.js
12708
12933
  var ContextSchema2 = external_exports.object({
12709
12934
  title: external_exports.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
12710
- description: external_exports.array(external_exports.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
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
+ )
12711
12938
  });
12712
12939
  var ParticipantSchema2 = external_exports.object({
12713
12940
  kind: external_exports.enum(["human", "agent"]),
@@ -12854,6 +13081,14 @@ var NotifyRequestSchema2 = external_exports.object({
12854
13081
  waiting: external_exports.enum(["none", "soft", "hard"]).optional().describe(
12855
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."
12856
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
+ ),
12857
13092
  /** #575: a RELAY of the user's explicitly stated preference, never the agent's
12858
13093
  * choice. Outranks waiting in both directions: 'call' rings even for a
12859
13094
  * waiting:'none' "call me when it's done"; 'message' never rings even for
@@ -12903,6 +13138,23 @@ var NotifyRequestSchema2 = external_exports.object({
12903
13138
  if (!needsOptions && r.options?.length)
12904
13139
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
12905
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
+ }
12906
13158
  var NotifyStatusSchema2 = external_exports.enum(["pending", "answered", "ignored"]);
12907
13159
  var AgentStateSchema2 = external_exports.enum(["idle", "in_progress", "completed", "needs_input"]);
12908
13160
  var SetTaskStateSchema2 = external_exports.object({
@@ -13085,6 +13337,34 @@ var NotifyResponseSchema2 = external_exports.object({
13085
13337
  answer: UserAnswerSchema2.optional(),
13086
13338
  answeredAt: external_exports.string().datetime().optional()
13087
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
+ });
13088
13368
  var UserResponseSchema2 = external_exports.object({
13089
13369
  requestId: external_exports.string(),
13090
13370
  answer: UserAnswerSchema2,
@@ -13139,6 +13419,20 @@ var InboxItemSchema2 = external_exports.object({
13139
13419
  /** The conversation thread + connection this item lives on. Present on the replied
13140
13420
  * detail — they power History's "Continue" / "New session from this" (#57/#251). */
13141
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(),
13142
13436
  tokenId: external_exports.string().optional(),
13143
13437
  status: NotifyStatusSchema2,
13144
13438
  context: ContextSchema2,
@@ -13149,6 +13443,16 @@ var InboxItemSchema2 = external_exports.object({
13149
13443
  * ring/enqueue time. Replaces the condensed line + index-aligned phrased points, which
13150
13444
  * between them could not express a call as a sequence. `question: null` is a real turn —
13151
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(),
13152
13456
  agenda: external_exports.array(AgendaTurnSchema2).optional(),
13153
13457
  /** On a replied detail (#397): the next steps the user attached to the answer
13154
13458
  * ("call back after lunch") — shown so they can see the commitment was captured. */
@@ -13173,6 +13477,23 @@ var InboxItemSchema2 = external_exports.object({
13173
13477
  * client may still flag a stall by age. Drives the inbox error badge + Retry. */
13174
13478
  error: external_exports.string().optional(),
13175
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(),
13176
13497
  select: external_exports.enum(["one", "many", "rank", "confirm", "text"]).default("one"),
13177
13498
  confirmStyle: external_exports.enum(["yesno", "approve"]).default("yesno").describe(
13178
13499
  "Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
@@ -13294,6 +13615,14 @@ var ConnectionSummarySchema2 = external_exports.object({
13294
13615
  provider: external_exports.string().nullable(),
13295
13616
  /** The pairing's assigned voice (#462); null = the default voice. */
13296
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(),
13297
13626
  createdAt: external_exports.string().datetime(),
13298
13627
  /** Most recent notification on this connection, either direction. Null = no contact yet.
13299
13628
  * Drives the agents-page recency grouping (Today / This week / …). */
@@ -13312,6 +13641,40 @@ var ConnectionSummarySchema2 = external_exports.object({
13312
13641
  * false = a local MCP connection running on the user's computer (Claude Code/Codex/…). */
13313
13642
  managed: external_exports.boolean()
13314
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
+ });
13315
13678
  var CreateRequestSchema2 = external_exports.object({
13316
13679
  /** The connection (token id) to send to, from GET /api/tokens. */
13317
13680
  tokenId: external_exports.string(),
@@ -13518,6 +13881,17 @@ var DeviceTokenSchema2 = external_exports.object({
13518
13881
  * a default silly name). */
13519
13882
  name: external_exports.string(),
13520
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(),
13521
13895
  phone_reveal: PairingRevealSchema2.nullable().optional(),
13522
13896
  // present once the phone reveals
13523
13897
  uik_pub: external_exports.string().nullable().optional()
@@ -13544,6 +13918,8 @@ var NotificationFeedbackKindSchema2 = external_exports.enum([
13544
13918
  // "There should be a picture or design here."
13545
13919
  "should_have_called",
13546
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."
13547
13923
  "other"
13548
13924
  // anything else — the note carries it.
13549
13925
  ]);
@@ -13658,6 +14034,9 @@ function levelFor(event) {
13658
14034
  case "idle":
13659
14035
  if (endsWithQuestion(event.result)) return "banner";
13660
14036
  return event.failed ? "push" : "inbox";
14037
+ case "work":
14038
+ return "inbox";
14039
+ // moot — entryFor drops work before a level ever matters
13661
14040
  case "error":
13662
14041
  return "inbox";
13663
14042
  }
@@ -13678,16 +14057,14 @@ function entryFor(event, state = {}) {
13678
14057
  switch (event.kind) {
13679
14058
  case "turn": {
13680
14059
  const who = event.role === "agent" ? "Agent" : "You";
13681
- const title = event.text ? `${who}: ${firstLine(event.text)}` : `${who} ran ${(event.tools ?? []).join(", ")}`;
13682
- const description = [event.text, event.tools?.length ? `Tools: ${event.tools.join(", ")}` : null].filter((c) => Boolean(c));
14060
+ const body = event.text ? event.role === "agent" ? event.text : `${who}: ${event.text}` : `${who} ran ${(event.tools ?? []).join(", ")}.`;
13683
14061
  return {
13684
14062
  ...common,
13685
- context: { title: clip(title), description: description.length ? description : [title] },
13686
- // The shaped form always carries an answer shape the contract's rule, and the
13687
- // right one: any inbox row can be replied to, and a reply to history is just
13688
- // the user initiating (the pump feeds it back in). Non-blocking, so it never
13689
- // reads as a question.
13690
- select: "text"
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).
13691
14068
  };
13692
14069
  }
13693
14070
  case "permission":
@@ -13709,16 +14086,24 @@ function entryFor(event, state = {}) {
13709
14086
  };
13710
14087
  case "idle": {
13711
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. ";
13712
14098
  return {
13713
14099
  ...common,
13714
- context: {
13715
- title: asking ? clip(firstLine(event.result ?? "")) : event.failed ? "The agent stopped without finishing" : "Turn complete",
13716
- description: [event.result || (event.failed ? "No result reported." : "Done.")]
13717
- },
13718
- select: "text",
14100
+ ask: `${lead}${result || (event.failed ? "No result reported." : "Done.")}`,
13719
14101
  ...asking ? { blocking: true } : {}
13720
14102
  };
13721
14103
  }
14104
+ case "work":
14105
+ return null;
14106
+ // log-only by contract — the live texture of the working log
13722
14107
  case "error":
13723
14108
  return null;
13724
14109
  }
@@ -13740,9 +14125,16 @@ function decisionFrom(answer) {
13740
14125
  }
13741
14126
  async function mirror(event, state, deps = {}) {
13742
14127
  const entry = entryFor(event, state);
14128
+ if (event.kind === "turn" && event.role === "agent" && event.text) state.lastAgentText = event.text;
13743
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
+ }
13744
14137
  try {
13745
- const req = NotifyRequestSchema2.parse(entry);
13746
14138
  const { notificationId, parentId } = await (deps.submit ?? submitNotification)(req);
13747
14139
  (state.mine ??= /* @__PURE__ */ new Set()).add(notificationId);
13748
14140
  return { parentId, notificationId };
@@ -13771,8 +14163,9 @@ async function askQuestion(question, state, deps = {}) {
13771
14163
  ...state.parentId ? { parentId: state.parentId } : {},
13772
14164
  ...state.repo ? { repo: state.repo } : {},
13773
14165
  ...state.branch ? { branch: state.branch } : {},
13774
- context: { title: clip(firstLine(question)), description: [question] },
13775
- 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,
13776
14169
  blocking: true,
13777
14170
  urgency: "banner"
13778
14171
  };
@@ -13807,7 +14200,6 @@ function spokenText(answer) {
13807
14200
  return null;
13808
14201
  }
13809
14202
  }
13810
- var firstLine = (s) => s.split("\n")[0] ?? s;
13811
14203
  var clip = (s) => (s.length > 120 ? `${s.slice(0, 117)}\u2026` : s) || "(no text)";
13812
14204
 
13813
14205
  // src/run.ts
@@ -13840,6 +14232,10 @@ function runHarness(opts) {
13840
14232
  opts.log(decision.allow ? `\u2713 approved: ${event.summary}` : `\u2717 denied: ${event.summary}`);
13841
14233
  return;
13842
14234
  }
14235
+ if (event.kind === "work") {
14236
+ opts.log(`\u2699 ${event.tool}${event.note ? ` \u2014 ${event.note.split("\n")[0] ?? ""}` : ""}`);
14237
+ return;
14238
+ }
13843
14239
  const { parentId } = await mirror(event, state, deps);
13844
14240
  state.parentId ??= parentId;
13845
14241
  if (event.kind === "turn") opts.log(`${event.role}: ${event.text.split("\n")[0] ?? ""}`);
@@ -13915,16 +14311,22 @@ function hostElsewhere() {
13915
14311
  function startHost(opts) {
13916
14312
  const runs = /* @__PURE__ */ new Map();
13917
14313
  const asHost = { token: opts.token };
13918
- const refreshNames = async () => {
14314
+ const refreshIdentities = async () => {
13919
14315
  for (const slot of listSlots()) {
13920
14316
  const token = readToken(slot);
13921
14317
  if (!token) continue;
13922
14318
  const me = await whoAmI({ token }).catch(() => null);
13923
- if (me?.name && me.name !== slotName(slot)) saveToken({ access_token: token, name: me.name, device: null }, slot);
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
+ }
13924
14324
  }
13925
14325
  };
13926
14326
  const beat = () => {
13927
- void refreshNames();
14327
+ void refreshIdentities();
14328
+ for (const r of runs.values()) if (r.token) void heartbeat(void 0, { token: r.token }).catch(() => {
14329
+ });
13928
14330
  void heartbeat({
13929
14331
  harnesses: detectAll().map((a) => ({ name: a.name, label: a.label, status: a.status })),
13930
14332
  workspaces: listWorkspaces(opts.wsDeps)
@@ -13949,25 +14351,28 @@ function startHost(opts) {
13949
14351
  },
13950
14352
  log: log2
13951
14353
  });
13952
- 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();
13953
14358
  try {
13954
- 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));
13955
14360
  } catch {
13956
14361
  }
13957
14362
  opts.log(`\u25B6 phone-launched ${label} (${spec.harness}) in ${spec.workspace}`);
13958
14363
  }
13959
14364
  }
13960
- const SLOT_HARNESS = { "mcp-agent": "claude", codex: "codex" };
13961
- const WAKE_PROMPT = "You were woken because Paigy work is waiting for you. This is a fresh session with your existing identity, so you may already have work in flight that you can't remember. Call check_replies FIRST, then get_thread on each conversation it returns and read it before you touch anything: it holds what you were doing, where (a repo or worktree may not be this folder), and what the owner already decided. Then handle what's waiting and follow your paigy instructions to stay in the loop.";
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.";
13962
14367
  async function sweepSlots() {
13963
- const workspace = listWorkspaces(opts.wsDeps)[0];
13964
- if (!workspace) return;
13965
14368
  for (const slot of listSlots()) {
13966
14369
  const harness = SLOT_HARNESS[slot] ?? (slot === "Desktop" ? void 0 : "claude");
13967
14370
  const key = `slot:${slot}`;
13968
14371
  if (!harness || runs.has(key)) continue;
13969
14372
  const token = readToken(slot);
13970
14373
  if (!token) continue;
14374
+ const workspace = resolveWakeDir(slotIdentity(slot).workspace, opts.wsDeps);
14375
+ if (!workspace) continue;
13971
14376
  const work = await checkReplies({ token }).catch(() => null);
13972
14377
  if (!work || work.requests.length === 0 && work.replies.length === 0) continue;
13973
14378
  const label = slotName(slot) ?? slot;
@@ -13986,7 +14391,9 @@ function startHost(opts) {
13986
14391
  },
13987
14392
  log: log2
13988
14393
  });
13989
- runs.set(key, { run, label });
14394
+ runs.set(key, { run, label, token });
14395
+ void heartbeat(void 0, { token }).catch(() => {
14396
+ });
13990
14397
  opts.log(`\u25B6 woke ${label} (${harness}) \u2014 work was waiting in ${workspace}`);
13991
14398
  }
13992
14399
  }
@@ -14019,12 +14426,21 @@ function startHost(opts) {
14019
14426
  const live = new Map([...runs.values()].map((r) => [r.label, r.run]));
14020
14427
  const names = /* @__PURE__ */ new Map();
14021
14428
  for (const key of listSlots()) if (key !== "Desktop") names.set(slotName(key) ?? key, key);
14022
- for (const label of live.keys()) if (!names.has(label)) names.set(label, label);
14023
- return [...names.keys()].map((name) => ({
14024
- name,
14025
- running: live.has(name),
14026
- working: live.get(name)?.working() ?? false
14027
- }));
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
+ });
14028
14444
  },
14029
14445
  stopSessions,
14030
14446
  stop() {
@@ -14045,7 +14461,7 @@ function startHost(opts) {
14045
14461
  var here = dirname4(fileURLToPath(import.meta.url));
14046
14462
  var wsDeps = { file: workspacesFile() };
14047
14463
  var DEVICE_SLOT = "Desktop";
14048
- var deviceToken = () => readToken(DEVICE_SLOT) || readToken();
14464
+ var deviceToken = () => readToken(DEVICE_SLOT);
14049
14465
  var win = null;
14050
14466
  function log(message) {
14051
14467
  win?.webContents.send("paigy:log", message);
@@ -14053,8 +14469,13 @@ function log(message) {
14053
14469
  var ICON = join5(here, "..", "assets", "icon.png");
14054
14470
  function createWindow() {
14055
14471
  win = new BrowserWindow({
14056
- width: 720,
14057
- 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,
14058
14479
  icon: ICON,
14059
14480
  // Windows/Linux window icon; macOS uses the dock icon below
14060
14481
  webPreferences: { preload: join5(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
@@ -14112,6 +14533,31 @@ ipcMain.handle("paigy:beacon", async () => {
14112
14533
  })();
14113
14534
  return { qr, code: code.user_code };
14114
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
+ });
14115
14561
  ipcMain.handle("paigy:workspaces", () => listWorkspaces(wsDeps));
14116
14562
  ipcMain.handle("paigy:workspace-add", async () => {
14117
14563
  const picked = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });