@paigy/harness 0.3.17 → 0.3.29

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # paigy-harness
2
2
 
3
+ Phone launches persist their selected harness and Paigy session identity with the local slot.
4
+ Recovery uses that runtime, never a default Claude process. Older slots need a known named
5
+ runtime or a Claude transcript; otherwise the host logs a missing-runtime blocker.
6
+
7
+ The Antigravity adapter reconnects once after its specific `subscriber fell behind updates`
8
+ failure, using the conversation ID reported by `agy`. It asks the resumed agent to inspect
9
+ existing results before continuing, rather than replaying the original task. Missing IDs,
10
+ other errors, and a failed recovery remain visible failures. Diagnostics name the conversation,
11
+ recovery attempt, and outcome. This mitigates the vendor stream failure; it does not repair
12
+ Antigravity's internal broadcaster. Keep verbose command output in files and read bounded summaries.
13
+
3
14
  Runs Claude Code and Codex on your machine, mirrors every turn to your Paigy inbox, and
4
15
  — when you ask it to — **rings your phone when an agent is blocked** (#804).
5
16
 
@@ -22,15 +33,14 @@ without a desktop app. Two things need a process that actually owns the agent:
22
33
  The point isn't notifications, it's a conversation with an agent that happens to be
23
34
  running on your laptop. Both directions close (`paigy/conversation.ts`):
24
35
 
25
- - **Answering.** The agent's turn ends on a question → it banners → your reply goes
26
- straight back into stdin as the next turn. Without this the loop is one-directional
27
- and dies at the first idle.
28
- - **Initiating.** You say something unprompted from your phone → it arrives as a
29
- `request` on the thread → same stdin. You never have to wait to be asked.
30
-
31
- Both ride the same thread, so the phone shows one conversation. `endsWithQuestion` is
32
- what decides "the agent is waiting on you" versus "here's your history", and it is
33
- deliberately conservative: a false positive buzzes someone who was never asked anything.
36
+ - **Answering.** The agent asks through Paigy's `contact` — its only channel to you; the
37
+ harness never relays its terminal output (`companion.md` Part 4, one channel) → your
38
+ reply lands on its Goal and goes straight back into stdin as the next turn. Without this
39
+ the loop is one-directional and dies at the first idle.
40
+ - **Initiating.** You say something unprompted from your phone → it arrives on the agent's
41
+ Goal → same stdin. You never have to wait to be asked. A Claude agent in the middle of a task
42
+ sees your words right after its next tool call, and gets them again as its next turn
43
+ (`companion.md` Part 4, words reach a running turn).
34
44
 
35
45
  Answers are read out of whatever shape they arrive in (`spokenText`) — a tapped option,
36
46
  a spoken sentence, a whole call log — because the words are what the agent needs, not
@@ -63,10 +73,8 @@ silent history from "answer me now" is `urgency`, not a second store:
63
73
 
64
74
  | Event | Level | Why |
65
75
  | --- | --- | --- |
66
- | a turn (agent or user) | `inbox` | lands, never buzzes; searchable, handoff-able history |
67
76
  | a blocked action | `banner` | claims attention; escalates on its own if unanswered |
68
77
  | a blocked **destructive** action | `call` | `rm -rf` doesn't get to wait for the escalation ladder |
69
- | a turn that **asked you something** | `banner` | a question filed silently is a conversation that dies |
70
78
  | a turn that failed | `push` | worth knowing soon, not worth a sound |
71
79
  | our own parse failure | — | not on the rail at all; it's our problem, not yours |
72
80
 
@@ -162,7 +170,7 @@ src/harness/ events.ts the neutral HarnessEvent the driver produces
162
170
  catalog.ts which harnesses exist, detection, auth probes, installs
163
171
  session.ts spawn, line-split, lifecycle — the only impure file here
164
172
  src/paigy/ level.ts event → NotifyLevel
165
- bridge.ts mirror turns, ask permissions/questions, setup nudges
173
+ bridge.ts permission asks, cannot-work notices, the turn's end as Goal progress
166
174
  conversation.ts the round trip — your words into a running agent's stdin
167
175
  src/triage/ evidence.ts the deterministic signals; judge.ts the one model boundary
168
176
  verdict.ts the prompt + its defensive parser; triage.ts the sweep itself
@@ -227,7 +235,9 @@ curl -fsSL https://paigy.ai/install | sh
227
235
 
228
236
  Installs the npm CLI (one self-contained file — the workspace libs are bundled) and
229
237
  runs `paigy-harness setup`: QR-pairs the machine, hatches each detected agent's
230
- identity (no codes), registers the MCP + statusline, grants a workspace, installs
238
+ identity (no codes), registers the Paigy MCP in every harness it runs (`MCP_WIRES` —
239
+ Claude Code, Codex, Antigravity: an agent's only channel to you) + the statusline,
240
+ grants a workspace, installs
231
241
  the host service. `paigy-harness service` installs a
232
242
  macOS launchd agent so hosting survives reboots — the host loop IS the service,
233
243
  launchd just keeps it alive (the old "no daemon" note was about the WAKE channel,
@@ -292,7 +302,9 @@ LAST, after the hooks are gone.
292
302
  - **Protocol churn is the standing risk.** Neither `stream-json` nor ACP adapter
293
303
  behavior is a stability contract. Every driver fails soft by design: an unrecognized
294
304
  frame is skipped, never fatal. If mirroring goes quiet after a CLI update, re-verify
295
- the frame names in the driver doc comments first.
305
+ the frame names in the driver doc comments first. An adapter can also leave a prompt
306
+ unanswered for good (claude-agent-acp#1145); the driver cancels one after a long quiet
307
+ with no tool running (`companion.md` Part 4, one way in).
296
308
  - **Auth probes are vendor commands** (`claude auth status`, `codex login status`) and
297
309
  can drift with CLI releases; a probe failure reads as "needs login", so drift shows
298
310
  up as a nagging hint, not a broken start.
package/dist/agy-acp.js CHANGED
@@ -22,6 +22,13 @@ var AgyAcp = class {
22
22
  bypass = false;
23
23
  started = false;
24
24
  promptId;
25
+ conversation;
26
+ dropped = false;
27
+ retried = false;
28
+ /** The vendor can fail on stderr without a result frame. Never retry other failures. */
29
+ diagnostic(text) {
30
+ if (text.includes("subscriber fell behind updates")) this.dropped = true;
31
+ }
25
32
  /** A frame from the harness. */
26
33
  fromClient(msg) {
27
34
  const out = { client: [], agy: [] };
@@ -50,6 +57,8 @@ var AgyAcp = class {
50
57
  out.start = { cwd: this.cwd, args: agyArgs(this.bypass) };
51
58
  }
52
59
  this.promptId = msg.id;
60
+ this.dropped = false;
61
+ this.retried = false;
53
62
  out.agy.push(JSON.stringify({ event: "user", message: { role: "user", content } }));
54
63
  return out;
55
64
  }
@@ -68,6 +77,10 @@ var AgyAcp = class {
68
77
  return out;
69
78
  }
70
79
  const update = (body) => out.client.push(frame({ method: "session/update", params: { sessionId: this.sessionId, update: body } }));
80
+ if (ev.event === "init" && typeof ev.conversation_id === "string" && /^[0-9a-f-]{36}$/i.test(ev.conversation_id)) {
81
+ this.conversation = ev.conversation_id;
82
+ out.diagnostic = `agy conversation=${this.conversation} recovery=${this.retried ? 1 : 0} connected`;
83
+ }
71
84
  if (ev.event === "step_update") {
72
85
  const s = ev.step_update ?? {};
73
86
  if (s.step_type === "agent_response" && typeof s.text_delta === "string" && s.text_delta) {
@@ -79,6 +92,9 @@ var AgyAcp = class {
79
92
  }
80
93
  if (ev.event === "result" && this.promptId !== void 0) {
81
94
  const r = ev.result ?? {};
95
+ if (r.status !== "SUCCESS") this.diagnostic(String(r.error ?? ""));
96
+ if (r.status !== "SUCCESS" && this.dropped && this.conversation && !this.retried) return out;
97
+ if (this.retried) out.diagnostic = `agy conversation=${this.conversation} recovery=1 outcome=${r.status === "SUCCESS" ? "success" : "failed"}`;
82
98
  const denied = Array.isArray(r.denied_actions) ? r.denied_actions : [];
83
99
  if (denied.length) {
84
100
  const names = denied.map((d) => d.display_name ?? d.action ?? "a tool").join(", ");
@@ -96,10 +112,20 @@ Antigravity cannot ask for permission when it runs in the background, so it was
96
112
  return out;
97
113
  }
98
114
  /** `agy` exited: a turn still open fails by name rather than hanging the harness. */
99
- exited(code) {
115
+ exited(code, recover = true) {
100
116
  const out = { client: [], agy: [] };
117
+ if (recover && this.promptId !== void 0 && this.dropped && this.conversation && !this.retried) {
118
+ this.retried = true;
119
+ this.dropped = false;
120
+ out.start = { cwd: this.cwd, args: [...agyArgs(this.bypass), "--conversation", this.conversation] };
121
+ out.agy.push(JSON.stringify({ event: "user", message: { role: "user", content: "The update stream disconnected. Inspect this conversation and existing command results before continuing the unfinished work. Do not repeat completed actions or blindly rerun the previous command. Redirect verbose command output to a file and read only a bounded summary. If the outcome of an action is uncertain, verify it before retrying; report a blocker if you cannot do so safely." } }));
122
+ out.diagnostic = `agy conversation=${this.conversation} recovery=1 reason=subscriber_lag exit=${code} reconnecting`;
123
+ return out;
124
+ }
101
125
  if (this.promptId !== void 0) {
102
- out.client.push(frame({ id: this.promptId, error: { code: -32e3, message: `agy exited ${code ?? "by signal"}` } }));
126
+ const message = this.retried ? `Antigravity stream recovery failed for conversation ${this.conversation}; inspect its results before retrying` : `agy exited ${code ?? "by signal"}`;
127
+ out.client.push(frame({ id: this.promptId, error: { code: -32e3, message } }));
128
+ out.diagnostic = `agy conversation=${this.conversation ?? "unknown"} recovery=${this.retried ? 1 : 0} outcome=failed exit=${code}`;
103
129
  this.promptId = void 0;
104
130
  }
105
131
  return out;
@@ -108,22 +134,31 @@ Antigravity cannot ask for permission when it runs in the background, so it was
108
134
  function main() {
109
135
  const adapter = new AgyAcp();
110
136
  let agy;
137
+ let closed = false;
111
138
  const apply = (out) => {
139
+ if (out.diagnostic) process.stderr.write(`${out.diagnostic}
140
+ `);
112
141
  for (const f of out.client) process.stdout.write(`${f}
113
142
  `);
114
143
  if (out.start) {
115
144
  agy = spawn("agy", out.start.args, { cwd: out.start.cwd, stdio: ["pipe", "pipe", "pipe"], env: process.env });
145
+ agy.stdin.on("error", () => {
146
+ });
116
147
  createInterface({ input: agy.stdout }).on("line", (l) => apply(adapter.fromAgy(l)));
117
- agy.stderr.on("data", (chunk) => process.stderr.write(chunk));
118
- agy.on("exit", (code) => {
119
- apply(adapter.exited(code));
120
- process.exit(code ?? 1);
148
+ let diagnosticTail = "";
149
+ agy.stderr.on("data", (chunk) => {
150
+ adapter.diagnostic(diagnosticTail + String(chunk));
151
+ diagnosticTail = (diagnosticTail + String(chunk)).slice(-512);
152
+ process.stderr.write(chunk);
153
+ });
154
+ agy.on("close", (code) => {
155
+ const next = adapter.exited(code, !closed);
156
+ apply(next);
157
+ if (!next.start) process.stdout.write("", () => process.exit(code ?? 1));
121
158
  });
122
159
  agy.on("error", (e) => {
123
160
  process.stderr.write(`agy: ${e.message}
124
161
  `);
125
- apply(adapter.exited(null));
126
- process.exit(1);
127
162
  });
128
163
  }
129
164
  for (const l of out.agy) agy?.stdin?.write(`${l}
@@ -135,6 +170,7 @@ function main() {
135
170
  } catch {
136
171
  }
137
172
  }).on("close", () => {
173
+ closed = true;
138
174
  agy?.stdin?.end();
139
175
  if (!agy) process.exit(0);
140
176
  });