@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.
- package/README.md +29 -1
- package/dist/cli.js +582 -78
- package/dist/main.js +598 -72
- 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-
|
|
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
|
|
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-
|
|
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
|
-
|
|
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)).
|
|
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
|
-
|
|
11108
|
-
|
|
11109
|
-
|
|
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
|
-
|
|
11112
|
-
const
|
|
11113
|
-
|
|
11114
|
-
|
|
11115
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
12096
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
12775
|
+
text: report,
|
|
12536
12776
|
...this.tools.length ? { tools: [...this.tools] } : {}
|
|
12537
12777
|
});
|
|
12538
12778
|
}
|
|
12539
|
-
const result =
|
|
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
|
-
|
|
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)).
|
|
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
|
|
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
|
-
|
|
13667
|
-
//
|
|
13668
|
-
//
|
|
13669
|
-
//
|
|
13670
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
13756
|
-
|
|
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 },
|
|
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
|
|
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
|
|
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 ${
|
|
14390
|
+
opts.log(`\u2716 ${label} ended`);
|
|
13937
14391
|
},
|
|
13938
14392
|
log: log2
|
|
13939
14393
|
});
|
|
13940
|
-
runs.set(key, { run, label
|
|
13941
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
14469
|
+
var ICON = join5(here, "..", "assets", "icon.png");
|
|
13978
14470
|
function createWindow() {
|
|
13979
14471
|
win = new BrowserWindow({
|
|
13980
|
-
|
|
13981
|
-
|
|
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:
|
|
14481
|
+
webPreferences: { preload: join5(here, "preload.cjs"), contextIsolation: true, nodeIntegration: false }
|
|
13985
14482
|
});
|
|
13986
|
-
void win.loadFile(
|
|
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"] });
|