@uuaid/pillar 0.2.0-alpha.7 → 0.2.0-alpha.8

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/bin/pillar.mjs CHANGED
@@ -323,6 +323,10 @@ async function cmdRecv(args) {
323
323
  url: process.env.PILLAR_ZESTRO_URL ?? args["zestro-url"],
324
324
  token: process.env.PILLAR_ZESTRO_TOKEN ?? args["zestro-token"],
325
325
  sessionId: process.env.PILLAR_ZESTRO_AGENT ?? args["zestro-agent"],
326
+ tabId: process.env.PILLAR_ZESTRO_TAB ?? args["zestro-tab"],
327
+ // Opt-in: force on a busy tab spawns a SECOND agent process resuming the
328
+ // same transcript (handle-spawn.js has no live-process guard). Default off.
329
+ force: /^1|true|yes$/i.test(process.env.PILLAR_ZESTRO_FORCE ?? "") || !!args["zestro-force"],
326
330
  });
327
331
  await bridge.connect();
328
332
  // Never log the full URL — it embeds the auth token.
@@ -336,24 +340,36 @@ async function cmdRecv(args) {
336
340
  if (bridge && env.kind === "request") {
337
341
  // Interaction-convention return leg: ack FIRST — pillar-ask allows 120s
338
342
  // per phase but ZestroBridge times out at 30s, so the ack goes out before
339
- // the bridged agent turn starts. Then reply with the turn's result text,
340
- // or status:"pending" on bridge timeout (command queued, turn running),
341
- // or status:"error" with the bridge failure as reason.
343
+ // the bridged agent turn starts. Reply statuses (verified against app code):
344
+ // "ok" = command_result received = renderer consumed / spawn initiated
345
+ // (NOT turn-complete — the turn's answer returns via a separate
346
+ // pillar send from the agent, if the prompt instructs one)
347
+ // "busy" = target tab busy, server rejected (fast, no 30s hang)
348
+ // "pending" = bridge timeout — the request was never confirmed
349
+ // "error" = bridge failure
342
350
  const thread = env.payload?.thread ?? env.id;
343
351
  const senderKey = env.transportSignature.publicKey; // open() F1: sender derives from this key
344
352
  try {
345
353
  await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
346
354
  const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
347
- const text = replyTextFromResult(result);
355
+ const hasText = result && typeof result === "object" &&
356
+ ["text", "result", "output", "response", "message"].some(k => k in result);
357
+ const text = hasText
358
+ ? replyTextFromResult(result)
359
+ : "zestro command_result received (server accepted) — agent turn answer, if any, returns via separate pillar send";
348
360
  await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "ok", text } });
349
361
  console.log(` → zestro reply sent (${text.length} bytes)`);
350
362
  } catch (e) {
351
- // command_result only arrives when the target agent's TURN completes —
352
- // a busy agent (long turn) exceeds any sane timeout while the command
353
- // is already queued in its tab. Timeout = accepted-unconfirmed, not failure.
354
- if (/timed out/.test(e.message)) {
355
- console.log(` → zestro: queued (command_result pending — agent busy)`);
356
- await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "pending", text: "request forwarded to zestro agent — turn still running" } });
363
+ // ZestroServerError SESSION_BUSY = server rejected: target tab busy and
364
+ // force off. Immediate distinct status — the sender can retry or drop.
365
+ if (e.code === "SESSION_BUSY") {
366
+ console.log(` → zestro: busy (${e.message})`);
367
+ await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "busy", reason: e.message.slice(0, 300), text: `zestro agent busy: ${e.message.slice(0, 300)}` } });
368
+ } else if (/timed out/.test(e.message)) {
369
+ // Bridge timeout = the server never confirmed delivery (renderer drop,
370
+ // cli-server gone, etc.) — accepted-unconfirmed, not turn-running.
371
+ console.log(` → zestro: unconfirmed (bridge timeout)`);
372
+ await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "pending", text: "request forwarded to zestro agent — delivery unconfirmed" } });
357
373
  } else {
358
374
  console.error(` → zestro bridge error: ${e.message}`);
359
375
  await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "error", reason: e.message.slice(0, 300), text: `zestro bridge error: ${e.message.slice(0, 300)}` } });
@@ -364,8 +380,10 @@ async function cmdRecv(args) {
364
380
  const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
365
381
  console.log(` → zestro result: ${JSON.stringify(result).slice(0, 200)}`);
366
382
  } catch (e) {
367
- if (/timed out/.test(e.message)) {
368
- console.log(` → zestro: queued (command_result pending — agent busy)`);
383
+ if (e.code === "SESSION_BUSY") {
384
+ console.log(` → zestro: busy (${e.message})`);
385
+ } else if (/timed out/.test(e.message)) {
386
+ console.log(` → zestro: unconfirmed (bridge timeout)`);
369
387
  } else {
370
388
  console.error(` → zestro bridge error: ${e.message}`);
371
389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uuaid/pillar",
3
- "version": "0.2.0-alpha.7",
3
+ "version": "0.2.0-alpha.8",
4
4
  "description": "IAASO-3050 reference implementation. Every agent runs its own PDP pillar, discovers peers via a Kademlia DHT, and exchanges cryptographically signed + Noise-encrypted messages over libp2p. One command to install and join the network.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,21 +1,45 @@
1
1
  /**
2
2
  * Zestro WebSocket bridge.
3
3
  *
4
- * Wire protocol (from ZZ's Mac audit):
4
+ * Wire protocol — verified against the OFFICIAL client contract
5
+ * (Zestro app.asar: dist/cli/maestro-client.ts, sourcesContent from
6
+ * maestro-cli.js.map) and the app's own server/renderer handlers:
5
7
  * ws://127.0.0.1:53522/<token>/ws
6
- * -> { type: "send_command", id: <uuid>, sessionId: <agentId>, command: <str>, inputMode: "ai" }
7
- * <- { type: "command_result", id: <uuid>, ... }
8
+ * -> { type: "send_command", requestId: "req_...", sessionId: <agentId>,
9
+ * command: <str>, inputMode: "ai", tabId?: <uuid>, force?: true }
10
+ * <- { type: "command_result", success, sessionId, tabId?, requestId } // echoes requestId
11
+ * <- { type: "error", message, sessionId } // NO requestId echo
12
+ *
13
+ * Correlation is on `requestId`, NOT `id` — the server echoes only
14
+ * `requestId`, so a client correlating on `id` can never match a reply.
15
+ *
16
+ * Semantics (verified in app code, all four layers):
17
+ * - command_result arrives IMMEDIATELY after the renderer receives the
18
+ * command — it means "consumed by the renderer / spawn initiated", NOT
19
+ * "the agent's turn completed". The turn answer returns out-of-band.
20
+ * - A busy target session WITHOUT force is rejected by the server
21
+ * ({type:"error"}), and the renderer has TWO more busy drops of its own.
22
+ * Errors carry no requestId, so they must be handled single-flight.
23
+ * - force=true on a BUSY tab spawns a SECOND agent process resuming the
24
+ * same session transcript (handle-spawn.js: no live-process guard,
25
+ * --resume <agentSessionId>). Concurrent, not queued. Opt-in only.
8
26
  *
9
27
  * Token lives in ~/Library/Application Support/zestro/cli-server.json.
10
28
  * Agent id for ZZ was verified to be 7fbce535-3a2f-4e6b-96e6-64926403efa5.
11
- *
12
- * Correlation on `id` so multiple concurrent sends don't race.
13
29
  */
14
30
  import { WebSocket } from "ws";
15
- import { randomUUID } from "node:crypto";
31
+
32
+ /** Server rejected the command (busy session, bad payload, ...). */
33
+ export class ZestroServerError extends Error {
34
+ constructor(message, code = "ZESTRO_SERVER_ERROR") {
35
+ super(message);
36
+ this.name = "ZestroServerError";
37
+ this.code = code;
38
+ }
39
+ }
16
40
 
17
41
  export class ZestroBridge {
18
- constructor({ url, token, sessionId, timeoutMs = 30000 }) {
42
+ constructor({ url, token, sessionId, tabId, timeoutMs = 30000, force = false }) {
19
43
  if (!url || !token || !sessionId) {
20
44
  throw new Error("ZestroBridge requires { url, token, sessionId }");
21
45
  }
@@ -26,7 +50,9 @@ export class ZestroBridge {
26
50
  }
27
51
  this.url = url;
28
52
  this.sessionId = sessionId;
53
+ this.tabId = tabId ?? null;
29
54
  this.timeoutMs = timeoutMs;
55
+ this.force = force;
30
56
  this._ws = null;
31
57
  this._pending = new Map();
32
58
  }
@@ -44,35 +70,81 @@ export class ZestroBridge {
44
70
  ws.on("message", (buf) => {
45
71
  let msg;
46
72
  try { msg = JSON.parse(buf.toString()); } catch { return; }
47
- if (msg.type === "command_result" && msg.id && this._pending.has(msg.id)) {
48
- const p = this._pending.get(msg.id);
49
- this._pending.delete(msg.id);
50
- p.resolve(msg);
73
+
74
+ // `echo` = the app did not recognize the message type (older build).
75
+ if (msg.type === "echo") {
76
+ const original = msg.data ?? {};
77
+ const reqId = original.requestId ?? msg.originalRequestId;
78
+ if (reqId && this._pending.has(reqId)) {
79
+ this._settle(reqId, p => p.reject(new ZestroServerError(
80
+ `Zestro app does not support '${original.type ?? "unknown"}' (older build?)`,
81
+ "ZESTRO_UNSUPPORTED"
82
+ )));
83
+ }
84
+ return;
85
+ }
86
+
87
+ // Exact correlation: server echoes requestId on command_result.
88
+ if (msg.requestId && this._pending.has(msg.requestId)) {
89
+ this._settle(msg.requestId, p => p.resolve(msg));
90
+ return;
91
+ }
92
+
93
+ // Single-flight: server `error` replies carry NO requestId (verified:
94
+ // sendError sends {type:'error', message, ...extra}). The bridge keeps
95
+ // at most one request in flight, so route orphan replies to it.
96
+ if (msg.type === "error") {
97
+ const message = msg.message ?? "zestro server error";
98
+ const code = /busy/i.test(message) ? "SESSION_BUSY" : "ZESTRO_SERVER_ERROR";
99
+ this._settleOldest(p => p.reject(new ZestroServerError(message, code)));
100
+ return;
101
+ }
102
+ if (msg.type === "command_result") {
103
+ this._settleOldest(p => p.resolve(msg));
51
104
  }
52
105
  });
53
106
  });
54
107
  }
55
108
 
56
- sendCommand({ command, inputMode = "ai" }) {
109
+ _settle(requestId, fn) {
110
+ const p = this._pending.get(requestId);
111
+ if (p) {
112
+ this._pending.delete(requestId);
113
+ fn(p);
114
+ }
115
+ }
116
+
117
+ _settleOldest(fn) {
118
+ const first = this._pending.entries().next();
119
+ if (!first.done) {
120
+ const [requestId, p] = first.value;
121
+ this._pending.delete(requestId);
122
+ fn(p);
123
+ }
124
+ }
125
+
126
+ sendCommand({ command, inputMode = "ai", force } = {}) {
57
127
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
58
128
  return Promise.reject(new Error("zestro bridge not connected"));
59
129
  }
60
- const id = randomUUID();
130
+ const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
61
131
  return new Promise((resolve, reject) => {
62
132
  const timer = setTimeout(() => {
63
- this._pending.delete(id);
64
- reject(new Error(`zestro send_command ${id} timed out after ${this.timeoutMs}ms`));
133
+ this._pending.delete(requestId);
134
+ reject(new Error(`zestro send_command ${requestId} timed out after ${this.timeoutMs}ms`));
65
135
  }, this.timeoutMs);
66
- this._pending.set(id, {
136
+ this._pending.set(requestId, {
67
137
  resolve: (msg) => { clearTimeout(timer); resolve(msg); },
68
138
  reject: (e) => { clearTimeout(timer); reject(e); },
69
139
  });
70
140
  this._ws.send(JSON.stringify({
71
141
  type: "send_command",
72
- id,
142
+ requestId,
73
143
  sessionId: this.sessionId,
74
144
  command,
75
145
  inputMode,
146
+ ...(this.tabId ? { tabId: this.tabId } : {}),
147
+ ...((force ?? this.force) ? { force: true } : {}),
76
148
  }));
77
149
  });
78
150
  }