@pushary/agent-hooks 0.39.0 → 0.42.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.
@@ -1,6 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- getApiKey
3
+ isDeferAnswer
4
+ } from "../chunk-KQYIHZ5E.js";
5
+ import {
6
+ askUser,
7
+ cancelQuestion,
8
+ deriveToolTarget,
9
+ describeToolCall,
10
+ fetchModeState,
11
+ getMachineId,
12
+ getPolicy,
13
+ resolvePolicy,
14
+ waitForAnswer
15
+ } from "../chunk-ETDXSKR5.js";
16
+ import "../chunk-DWED7BS3.js";
17
+ import "../chunk-Z5PL3K7C.js";
18
+ import {
19
+ getApiKey,
20
+ getBaseUrl
4
21
  } from "../chunk-NKXSILEW.js";
5
22
 
6
23
  // src/wrapper/claudeBinary.ts
@@ -29,8 +46,17 @@ var findClaudeBinary = () => {
29
46
  return null;
30
47
  };
31
48
 
32
- // src/wrapper/localPassthrough.ts
49
+ // src/wrapper/spawnClaude.ts
33
50
  import { spawn } from "child_process";
51
+ var needsShell = (binary, platform = process.platform) => platform === "win32" && /\.(cmd|bat)$/i.test(binary);
52
+ var spawnClaude = (binary, args2, options) => {
53
+ if (needsShell(binary)) {
54
+ return spawn(`"${binary}"`, args2, { ...options, shell: true });
55
+ }
56
+ return spawn(binary, args2, options);
57
+ };
58
+
59
+ // src/wrapper/localPassthrough.ts
34
60
  var SIGNAL_NUMBERS = {
35
61
  SIGHUP: 1,
36
62
  SIGINT: 2,
@@ -43,7 +69,7 @@ var runLocalPassthrough = (binary, args2) => {
43
69
  return new Promise((resolve) => {
44
70
  let child;
45
71
  try {
46
- child = spawn(binary, args2, {
72
+ child = spawnClaude(binary, args2, {
47
73
  stdio: "inherit",
48
74
  env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" }
49
75
  });
@@ -74,57 +100,874 @@ var runLocalPassthrough = (binary, args2) => {
74
100
  });
75
101
  };
76
102
 
103
+ // src/wrapper/remoteMode.ts
104
+ import { basename } from "path";
105
+
106
+ // src/wrapper/sdkLoader.ts
107
+ var userMessage = (text) => ({
108
+ type: "user",
109
+ message: { role: "user", content: text },
110
+ parent_tool_use_id: null
111
+ });
112
+ var loadClaudeSdk = async () => {
113
+ const specifier = "@anthropic-ai/claude-agent-sdk";
114
+ try {
115
+ const mod = await import(specifier);
116
+ if (typeof mod.query === "function") return mod;
117
+ if (mod.default && typeof mod.default.query === "function") return mod.default;
118
+ return null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ };
123
+
124
+ // src/wrapper/messageQueue.ts
125
+ var BoundedInputQueue = class {
126
+ buffer = [];
127
+ waiter;
128
+ closed = false;
129
+ cap;
130
+ onDrop;
131
+ coalesce;
132
+ pushedCount = 0;
133
+ deliveredCount = 0;
134
+ droppedCount = 0;
135
+ constructor(options = {}) {
136
+ this.cap = Math.max(1, options.cap ?? 32);
137
+ this.onDrop = options.onDrop;
138
+ this.coalesce = options.coalesce;
139
+ }
140
+ push(item) {
141
+ if (this.closed) return;
142
+ this.pushedCount++;
143
+ if (this.waiter) {
144
+ const resolve = this.waiter;
145
+ this.waiter = void 0;
146
+ this.deliveredCount++;
147
+ resolve({ value: item, done: false });
148
+ return;
149
+ }
150
+ this.buffer.push(item);
151
+ while (this.buffer.length > this.cap) {
152
+ const dropped = this.buffer.shift();
153
+ this.droppedCount++;
154
+ this.onDrop?.(dropped, this.droppedCount);
155
+ }
156
+ }
157
+ // Mark done. Any parked consumer completes; further push() is ignored. The
158
+ // buffer is cleared so nothing is retained after shutdown.
159
+ close() {
160
+ if (this.closed) return;
161
+ this.closed = true;
162
+ this.buffer.length = 0;
163
+ if (this.waiter) {
164
+ const resolve = this.waiter;
165
+ this.waiter = void 0;
166
+ resolve({ value: void 0, done: true });
167
+ }
168
+ }
169
+ // End the CURRENT consumer without closing the queue, so a fresh query can
170
+ // attach after a restart and keep draining the same buffered items. Resolving
171
+ // the parked waiter as done lets the abandoned query's for-await unwind cleanly
172
+ // instead of hanging on a promise that would otherwise never settle.
173
+ detachConsumer() {
174
+ if (this.waiter) {
175
+ const resolve = this.waiter;
176
+ this.waiter = void 0;
177
+ resolve({ value: void 0, done: true });
178
+ }
179
+ }
180
+ get isClosed() {
181
+ return this.closed;
182
+ }
183
+ get bufferedCount() {
184
+ return this.buffer.length;
185
+ }
186
+ get stats() {
187
+ return { pushed: this.pushedCount, delivered: this.deliveredCount, dropped: this.droppedCount };
188
+ }
189
+ [Symbol.asyncIterator]() {
190
+ return {
191
+ next: () => {
192
+ if (this.buffer.length > 0) {
193
+ if (this.coalesce && this.buffer.length > 1) {
194
+ const items = this.buffer.splice(0, this.buffer.length);
195
+ this.deliveredCount += items.length;
196
+ return Promise.resolve({ value: this.coalesce(items), done: false });
197
+ }
198
+ const value = this.buffer.shift();
199
+ this.deliveredCount++;
200
+ return Promise.resolve({ value, done: false });
201
+ }
202
+ if (this.closed) {
203
+ return Promise.resolve({ value: void 0, done: true });
204
+ }
205
+ if (this.waiter) {
206
+ return Promise.reject(
207
+ new Error("BoundedInputQueue: concurrent consumers are not supported")
208
+ );
209
+ }
210
+ return new Promise((resolve) => {
211
+ this.waiter = resolve;
212
+ });
213
+ },
214
+ return: () => {
215
+ this.close();
216
+ return Promise.resolve({ value: void 0, done: true });
217
+ }
218
+ };
219
+ }
220
+ };
221
+
222
+ // src/wrapper/approver.ts
223
+ var DEFAULT_MAX_CONCURRENT = 16;
224
+ var DEFAULT_APPROVAL_WINDOW_MS = 5 * 6e4;
225
+ var MAX_APPROVAL_WINDOW_MS = 30 * 6e4;
226
+ var allow = (input) => ({
227
+ behavior: "allow",
228
+ updatedInput: input
229
+ });
230
+ var deny = (message) => ({ behavior: "deny", message });
231
+ var createRemoteApprover = (deps) => {
232
+ const fetchModeState2 = deps.fetchModeState ?? fetchModeState;
233
+ const getPolicy2 = deps.getPolicy ?? getPolicy;
234
+ const resolvePolicy2 = deps.resolvePolicy ?? resolvePolicy;
235
+ const askUser2 = deps.askUser ?? askUser;
236
+ const waitForAnswer2 = deps.waitForAnswer ?? waitForAnswer;
237
+ const cancelQuestion2 = deps.cancelQuestion ?? cancelQuestion;
238
+ const now = deps.now ?? (() => Date.now());
239
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
240
+ const maxConcurrent = Math.max(1, deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT);
241
+ const pending = /* @__PURE__ */ new Set();
242
+ let tornDown = false;
243
+ const windowFor = (policy) => {
244
+ const base = policy.timeoutSeconds > 0 ? policy.timeoutSeconds * 1e3 : deps.approvalWindowMs ?? DEFAULT_APPROVAL_WINDOW_MS;
245
+ const ceiling = policy.timeoutAction === "wait" ? MAX_APPROVAL_WINDOW_MS : base;
246
+ return Math.min(Math.max(base, 15e3), Math.max(ceiling, 15e3));
247
+ };
248
+ const applyTimeout = (policy, input) => policy.timeoutAction === "approve" ? allow(input) : deny("No approval received in time");
249
+ const waitForPhone = async (entry, correlationId, windowMs) => {
250
+ const deadline = now() + windowMs;
251
+ while (now() < deadline && !entry.controller.signal.aborted) {
252
+ const remaining = Math.min(Math.max(deadline - now(), 1e3), 3e4);
253
+ const abortPromise = new Promise((resolve) => {
254
+ entry.controller.signal.addEventListener("abort", () => resolve({ answered: false, aborted: true }), {
255
+ once: true
256
+ });
257
+ });
258
+ let outcome;
259
+ try {
260
+ outcome = await Promise.race([
261
+ waitForAnswer2(deps.apiKey, correlationId, remaining),
262
+ abortPromise
263
+ ]);
264
+ } catch {
265
+ if (now() + 1e3 >= deadline) break;
266
+ await sleep(1e3);
267
+ continue;
268
+ }
269
+ if ("aborted" in outcome) return { answered: false };
270
+ if (outcome.answered) return outcome;
271
+ if (now() + 500 >= deadline) break;
272
+ await sleep(500);
273
+ }
274
+ return { answered: false };
275
+ };
276
+ const canUseTool = async (toolName, input, options) => {
277
+ if (toolName.startsWith("mcp__pushary__")) return allow(input);
278
+ if (tornDown) return deny("Remote session is shutting down");
279
+ if (pending.size >= maxConcurrent) {
280
+ deps.log?.(`[pushary] too many concurrent approvals (${pending.size}); denying ${toolName}`);
281
+ return deny("Too many pending approvals; try again");
282
+ }
283
+ const controller = new AbortController();
284
+ const entry = { controller };
285
+ pending.add(entry);
286
+ const outerAbort = () => controller.abort();
287
+ if (options?.signal) {
288
+ if (options.signal.aborted) controller.abort();
289
+ else options.signal.addEventListener("abort", outerAbort, { once: true });
290
+ }
291
+ try {
292
+ const sessionId = deps.getSessionId?.();
293
+ const modeState = await fetchModeState2(deps.apiKey, sessionId);
294
+ if (modeState.kill) return deny("Stopped by user \u2014 this agent was halted from Pushary");
295
+ const policyConfig = await getPolicy2(deps.apiKey, modeState.policyVersion);
296
+ const policy = resolvePolicy2(policyConfig, toolName, modeState.mode, input);
297
+ if (policy.timeoutSeconds === 0 && policy.timeoutAction === "approve") return allow(input);
298
+ if (policy.timeoutSeconds === 0 && policy.timeoutAction === "deny") {
299
+ return deny(`Denied by policy for ${policy.tool}`);
300
+ }
301
+ const description = describeToolCall(toolName, input, "hook");
302
+ const toolTarget = deriveToolTarget(toolName, input);
303
+ let question;
304
+ try {
305
+ question = await askUser2(deps.apiKey, {
306
+ question: `Allow ${description}?`,
307
+ type: "confirm",
308
+ context: `Your agent wants to run this in ${deps.projectName} (you are away)`,
309
+ agentName: `Claude Code - ${deps.projectName}`,
310
+ sessionId,
311
+ machineId: deps.machineId,
312
+ toolName,
313
+ toolTarget
314
+ });
315
+ } catch {
316
+ return applyTimeout(policy, input);
317
+ }
318
+ entry.correlationId = question.correlationId;
319
+ if (question.noDevices) return applyTimeout(policy, input);
320
+ const answer = await waitForPhone(entry, question.correlationId, windowFor(policy));
321
+ if (answer.answered) {
322
+ if (isDeferAnswer(answer.value)) return deny("Deferred \u2014 handle it on the machine");
323
+ return answer.value === "yes" ? allow(input) : deny("Denied from your phone");
324
+ }
325
+ return applyTimeout(policy, input);
326
+ } catch {
327
+ return deny("Approval unavailable; denied by default");
328
+ } finally {
329
+ if (options?.signal) options.signal.removeEventListener("abort", outerAbort);
330
+ pending.delete(entry);
331
+ }
332
+ };
333
+ const teardown = () => {
334
+ tornDown = true;
335
+ for (const entry of pending) {
336
+ entry.controller.abort();
337
+ if (entry.correlationId) {
338
+ void cancelQuestion2(deps.apiKey, entry.correlationId).catch(() => {
339
+ });
340
+ }
341
+ }
342
+ pending.clear();
343
+ };
344
+ return {
345
+ canUseTool,
346
+ teardown,
347
+ pendingCount: () => pending.size
348
+ };
349
+ };
350
+
351
+ // src/wrapper/drainClient.ts
352
+ var drainPendingCommand = async (apiKey, sessionId, signal, baseUrl = getBaseUrl()) => {
353
+ try {
354
+ const response = await fetch(`${baseUrl}/api/agent/command/drain`, {
355
+ method: "POST",
356
+ headers: {
357
+ "Content-Type": "application/json",
358
+ Authorization: `Bearer ${apiKey}`
359
+ },
360
+ body: JSON.stringify(sessionId ? { sessionId } : {}),
361
+ signal
362
+ });
363
+ if (!response.ok) return null;
364
+ const data = await response.json();
365
+ return typeof data.command === "string" && data.command.length > 0 ? data.command : null;
366
+ } catch {
367
+ return null;
368
+ }
369
+ };
370
+
77
371
  // src/wrapper/commandPoller.ts
78
- var FAST_POLL_MS = 2e3;
79
372
  var SLOW_POLL_MS = 3e4;
80
- var isEnabled = () => {
81
- const flag = process.env.PUSHARY_WRAPPER_POLL;
82
- return flag === "1" || flag === "true";
83
- };
373
+ var FAST_POLL_MS = 2e3;
374
+ var FAST_DECAY_TICKS = 3;
375
+ var MAX_ERROR_BACKOFF_MS = 12e4;
376
+ var MAX_ERROR_STREAK = 6;
377
+ var REQUEST_TIMEOUT_MS = 1e4;
84
378
  var startCommandPoller = (opts) => {
379
+ const drain = opts.drain ?? drainPendingCommand;
380
+ const slow = opts.slowPollMs ?? SLOW_POLL_MS;
381
+ const fast = opts.fastPollMs ?? FAST_POLL_MS;
382
+ const requestTimeout = opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
85
383
  let stopped = false;
86
384
  let timer;
87
- if (!isEnabled()) return { stop() {
88
- } };
89
- try {
90
- getApiKey();
91
- } catch {
92
- return { stop() {
93
- } };
94
- }
385
+ let inflight;
386
+ let idleTicks = 0;
387
+ let errorStreak = 0;
388
+ const jitter = (ms) => {
389
+ const r = opts.jitter ? opts.jitter() : Math.random();
390
+ return Math.max(1, Math.round(ms * (0.85 + r * 0.3)));
391
+ };
392
+ const nextDelay = () => {
393
+ if (errorStreak > 0) {
394
+ return Math.min(slow * 2 ** (errorStreak - 1), MAX_ERROR_BACKOFF_MS);
395
+ }
396
+ return idleTicks >= FAST_DECAY_TICKS ? slow : fast;
397
+ };
95
398
  const schedule = (delayMs) => {
96
399
  if (stopped) return;
97
- timer = setTimeout(tick, delayMs);
400
+ timer = setTimeout(tick, jitter(delayMs));
401
+ };
402
+ const fetchMode = async () => {
403
+ if (!opts.fetchModeState || !opts.onModeState) return;
404
+ try {
405
+ const state = await opts.fetchModeState(opts.apiKey, opts.getSessionId?.());
406
+ try {
407
+ opts.onModeState(state);
408
+ } catch {
409
+ }
410
+ } catch {
411
+ }
98
412
  };
99
413
  const tick = async () => {
100
414
  if (stopped) return;
101
- let next = SLOW_POLL_MS;
415
+ const controller = new AbortController();
416
+ inflight = controller;
417
+ const timeout = setTimeout(() => controller.abort(), requestTimeout);
102
418
  try {
103
- const command = await drainPendingCommand(opts.sessionId);
419
+ const wantCommands = opts.shouldDrainCommands ? opts.shouldDrainCommands() : true;
420
+ const [command] = await Promise.all([
421
+ wantCommands ? drain(opts.apiKey, opts.getSessionId?.(), controller.signal) : Promise.resolve(null),
422
+ fetchMode()
423
+ ]);
424
+ errorStreak = 0;
104
425
  if (command) {
105
- next = FAST_POLL_MS;
106
- opts.onCommand(command);
426
+ idleTicks = 0;
427
+ try {
428
+ opts.onCommand(command);
429
+ } catch {
430
+ }
431
+ } else {
432
+ idleTicks++;
107
433
  }
108
434
  } catch {
435
+ errorStreak = Math.min(errorStreak + 1, MAX_ERROR_STREAK);
436
+ } finally {
437
+ clearTimeout(timeout);
438
+ inflight = void 0;
109
439
  }
110
- schedule(next);
440
+ schedule(nextDelay());
111
441
  };
112
- opts.log?.("[pushary] wrapper command poller enabled (experimental)");
113
- schedule(SLOW_POLL_MS);
442
+ schedule(fast);
443
+ opts.log?.("[pushary] wrapper command poller active (experimental)");
114
444
  return {
115
445
  stop() {
116
446
  stopped = true;
117
447
  if (timer) clearTimeout(timer);
448
+ inflight?.abort();
449
+ inflight = void 0;
118
450
  }
119
451
  };
120
452
  };
121
- var drainPendingCommand = async (_sessionId) => {
122
- return null;
453
+
454
+ // src/wrapper/wsProtocol.ts
455
+ var PROTOCOL_VERSION = 1;
456
+ var TRANSCRIPT_TEXT_MAX = 4e3;
457
+ var encodeFrame = (frame) => JSON.stringify(frame);
458
+ var helloFrame = (fields) => ({ v: PROTOCOL_VERSION, t: "hello", ...fields });
459
+ var transcriptFrame = (seq, kind, text, meta) => ({
460
+ v: PROTOCOL_VERSION,
461
+ t: "transcript",
462
+ seq,
463
+ kind,
464
+ ...text !== void 0 ? { text: text.slice(0, TRANSCRIPT_TEXT_MAX) } : {},
465
+ ...meta ? { meta } : {}
466
+ });
467
+ var parse = (raw) => {
468
+ try {
469
+ const value = JSON.parse(raw);
470
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
471
+ const obj = value;
472
+ if (obj.v !== PROTOCOL_VERSION) return null;
473
+ return obj;
474
+ } catch {
475
+ return null;
476
+ }
477
+ };
478
+ var isString = (v) => typeof v === "string";
479
+ var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
480
+ var decodeServerFrame = (raw) => {
481
+ const f = parse(raw);
482
+ if (!f) return null;
483
+ switch (f.t) {
484
+ case "welcome":
485
+ return isFiniteNumber(f.resumeFromSeq) ? { v: PROTOCOL_VERSION, t: "welcome", resumeFromSeq: f.resumeFromSeq } : null;
486
+ case "ping":
487
+ return { v: PROTOCOL_VERSION, t: "ping" };
488
+ case "pong":
489
+ return { v: PROTOCOL_VERSION, t: "pong" };
490
+ case "command":
491
+ return isString(f.id) && isString(f.text) ? { v: PROTOCOL_VERSION, t: "command", id: f.id, text: f.text } : null;
492
+ case "resume":
493
+ return isFiniteNumber(f.afterSeq) ? { v: PROTOCOL_VERSION, t: "resume", afterSeq: f.afterSeq } : null;
494
+ case "error":
495
+ if (isString(f.code) && isString(f.message)) {
496
+ return { v: PROTOCOL_VERSION, t: "error", code: f.code, message: f.message, fatal: f.fatal === true };
497
+ }
498
+ return null;
499
+ default:
500
+ return null;
501
+ }
502
+ };
503
+
504
+ // src/wrapper/relayClient.ts
505
+ var DEFAULT_HEARTBEAT_MS = 2e4;
506
+ var DEFAULT_PONG_TIMEOUT_MS = 1e4;
507
+ var DEFAULT_MAX_OUTBOUND = 128;
508
+ var DEFAULT_MAX_SEEN_COMMANDS = 256;
509
+ var defaultBackoff = (attempt) => Math.min(1e3 * 2 ** attempt, 3e4);
510
+ var defaultSocketFactory = (url) => {
511
+ const WS = globalThis.WebSocket;
512
+ if (!WS) throw new Error("no WebSocket in this runtime");
513
+ const socket = new WS(url);
514
+ return {
515
+ send: (data) => socket.send(data),
516
+ close: () => socket.close(),
517
+ onOpen: (cb) => socket.addEventListener("open", () => cb()),
518
+ onMessage: (cb) => socket.addEventListener("message", (event) => cb(String(event.data))),
519
+ onClose: (cb) => socket.addEventListener("close", () => cb()),
520
+ onError: (cb) => socket.addEventListener("error", (event) => cb(event))
521
+ };
522
+ };
523
+ var createRelayClient = (options) => {
524
+ const factory = options.socketFactory ?? defaultSocketFactory;
525
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
526
+ const pongTimeoutMs = options.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
527
+ const backoff = options.backoffMs ?? defaultBackoff;
528
+ const maxOutbound = Math.max(1, options.maxOutbound ?? DEFAULT_MAX_OUTBOUND);
529
+ const maxSeenCommands = Math.max(1, options.maxSeenCommands ?? DEFAULT_MAX_SEEN_COMMANDS);
530
+ let socket;
531
+ let isConnected = false;
532
+ let stopped = false;
533
+ let seq = 0;
534
+ let attempt = 0;
535
+ let reconnectTimer;
536
+ let heartbeatTimer;
537
+ let pongTimer;
538
+ const outbound = [];
539
+ const seenCommands = [];
540
+ const seenSet = /* @__PURE__ */ new Set();
541
+ const log = (message) => options.log?.(message);
542
+ const clearTimers = () => {
543
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
544
+ if (pongTimer) clearTimeout(pongTimer);
545
+ if (reconnectTimer) clearTimeout(reconnectTimer);
546
+ heartbeatTimer = void 0;
547
+ pongTimer = void 0;
548
+ reconnectTimer = void 0;
549
+ };
550
+ const rememberCommand = (id) => {
551
+ if (seenSet.has(id)) return false;
552
+ seenSet.add(id);
553
+ seenCommands.push(id);
554
+ while (seenCommands.length > maxSeenCommands) {
555
+ const evicted = seenCommands.shift();
556
+ if (evicted) seenSet.delete(evicted);
557
+ }
558
+ return true;
559
+ };
560
+ const sendFrame = (frame) => {
561
+ if (!socket || !isConnected) return;
562
+ try {
563
+ socket.send(encodeFrame(frame));
564
+ } catch {
565
+ }
566
+ };
567
+ const enqueueTranscript = (frame) => {
568
+ if (isConnected) {
569
+ sendFrame(frame);
570
+ return;
571
+ }
572
+ outbound.push(frame);
573
+ while (outbound.length > maxOutbound) outbound.shift();
574
+ };
575
+ const flushOutbound = () => {
576
+ while (outbound.length > 0 && isConnected) {
577
+ const frame = outbound.shift();
578
+ if (frame) sendFrame(frame);
579
+ }
580
+ };
581
+ const armPong = () => {
582
+ if (pongTimer) clearTimeout(pongTimer);
583
+ pongTimer = setTimeout(() => {
584
+ log("[pushary] relay heartbeat timed out; reconnecting");
585
+ dropAndReconnect();
586
+ }, pongTimeoutMs);
587
+ };
588
+ const startHeartbeat = () => {
589
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
590
+ heartbeatTimer = setInterval(() => {
591
+ sendFrame({ v: PROTOCOL_VERSION, t: "ping" });
592
+ armPong();
593
+ }, heartbeatMs);
594
+ };
595
+ const onServerFrame = (raw) => {
596
+ const frame = decodeServerFrame(raw);
597
+ if (!frame) return;
598
+ switch (frame.t) {
599
+ case "welcome":
600
+ attempt = 0;
601
+ flushOutbound();
602
+ log("[pushary] relay connected");
603
+ break;
604
+ case "command":
605
+ sendFrame({ v: PROTOCOL_VERSION, t: "ack", id: frame.id });
606
+ if (rememberCommand(frame.id)) {
607
+ try {
608
+ options.onCommand(frame.text);
609
+ } catch {
610
+ }
611
+ }
612
+ break;
613
+ case "ping":
614
+ sendFrame({ v: PROTOCOL_VERSION, t: "pong" });
615
+ break;
616
+ case "pong":
617
+ if (pongTimer) clearTimeout(pongTimer);
618
+ pongTimer = void 0;
619
+ break;
620
+ case "resume":
621
+ log(`[pushary] relay signalled a gap; catch up after seq ${frame.afterSeq}`);
622
+ break;
623
+ case "error":
624
+ if (frame.fatal) {
625
+ log(`[pushary] relay fatal: ${frame.message}`);
626
+ fatal(frame.message);
627
+ } else {
628
+ log(`[pushary] relay error: ${frame.message}`);
629
+ }
630
+ break;
631
+ }
632
+ };
633
+ const dropSocket = () => {
634
+ isConnected = false;
635
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
636
+ if (pongTimer) clearTimeout(pongTimer);
637
+ heartbeatTimer = void 0;
638
+ pongTimer = void 0;
639
+ if (socket) {
640
+ try {
641
+ socket.close();
642
+ } catch {
643
+ }
644
+ }
645
+ socket = void 0;
646
+ };
647
+ const scheduleReconnect = () => {
648
+ if (stopped) return;
649
+ if (reconnectTimer) return;
650
+ const delay = backoff(attempt);
651
+ attempt++;
652
+ const jittered = Math.max(1, Math.round(delay * (0.85 + Math.random() * 0.3)));
653
+ reconnectTimer = setTimeout(() => {
654
+ reconnectTimer = void 0;
655
+ connect();
656
+ }, jittered);
657
+ };
658
+ const dropAndReconnect = () => {
659
+ dropSocket();
660
+ scheduleReconnect();
661
+ };
662
+ const fatal = (message) => {
663
+ stopped = true;
664
+ clearTimers();
665
+ dropSocket();
666
+ options.onFatal?.(message);
667
+ };
668
+ const connect = () => {
669
+ if (stopped) return;
670
+ let created;
671
+ try {
672
+ created = factory(options.url);
673
+ } catch (error) {
674
+ fatal(error instanceof Error ? error.message : String(error));
675
+ return;
676
+ }
677
+ socket = created;
678
+ created.onOpen(() => {
679
+ isConnected = true;
680
+ sendFrame(
681
+ helloFrame({
682
+ apiKey: options.apiKey,
683
+ machineId: options.machineId,
684
+ agentType: options.agentType,
685
+ sessionId: options.getSessionId(),
686
+ lastSeq: seq
687
+ })
688
+ );
689
+ startHeartbeat();
690
+ });
691
+ created.onMessage((data) => onServerFrame(data));
692
+ created.onClose(() => {
693
+ if (stopped) return;
694
+ dropAndReconnect();
695
+ });
696
+ created.onError(() => {
697
+ });
698
+ };
699
+ return {
700
+ start() {
701
+ if (stopped) return;
702
+ connect();
703
+ },
704
+ connected() {
705
+ return isConnected;
706
+ },
707
+ sendTranscript(kind, text, meta) {
708
+ if (stopped) return;
709
+ seq++;
710
+ enqueueTranscript(transcriptFrame(seq, kind, text, meta));
711
+ },
712
+ reannounce() {
713
+ if (stopped || !isConnected) return;
714
+ sendFrame(
715
+ helloFrame({
716
+ apiKey: options.apiKey,
717
+ machineId: options.machineId,
718
+ agentType: options.agentType,
719
+ sessionId: options.getSessionId(),
720
+ lastSeq: seq
721
+ })
722
+ );
723
+ },
724
+ stop() {
725
+ if (stopped) return;
726
+ stopped = true;
727
+ sendFrame({ v: PROTOCOL_VERSION, t: "bye" });
728
+ clearTimers();
729
+ dropSocket();
730
+ }
731
+ };
732
+ };
733
+
734
+ // src/wrapper/remoteLoop.ts
735
+ var errMsg = (err) => err instanceof Error ? err.message : String(err);
736
+ var DEFAULT_MAX_RESTARTS = 5;
737
+ var runRemoteLoop = async (deps) => {
738
+ const maxRestarts = deps.maxRestarts ?? DEFAULT_MAX_RESTARTS;
739
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
740
+ const backoff = deps.backoffMs ?? ((attempt) => Math.min(1e3 * 2 ** attempt, 15e3));
741
+ let resumeId = deps.initialResume;
742
+ let totalCost = 0;
743
+ let restarts = 0;
744
+ while (!deps.signal.aborted) {
745
+ deps.input.detachConsumer();
746
+ let query;
747
+ try {
748
+ query = deps.sdk.query({
749
+ prompt: deps.input,
750
+ options: {
751
+ resume: resumeId,
752
+ permissionMode: deps.permissionMode,
753
+ canUseTool: deps.canUseTool,
754
+ env: deps.env,
755
+ cwd: deps.cwd
756
+ }
757
+ });
758
+ } catch (err) {
759
+ deps.log?.(`[pushary] remote mode: query failed to start: ${errMsg(err)}`);
760
+ return { exitCode: 1, totalCostUsd: totalCost };
761
+ }
762
+ const onAbort = () => {
763
+ deps.input.close();
764
+ void query.interrupt?.().catch(() => {
765
+ });
766
+ };
767
+ if (deps.signal.aborted) onAbort();
768
+ else deps.signal.addEventListener("abort", onAbort, { once: true });
769
+ try {
770
+ for await (const message of query) {
771
+ deps.onMessage?.(message);
772
+ if (message.type === "system" && message.subtype === "init" && typeof message.session_id === "string") {
773
+ resumeId = message.session_id;
774
+ deps.onSessionId?.(resumeId);
775
+ }
776
+ if (message.type === "result" && typeof message.total_cost_usd === "number") {
777
+ totalCost = message.total_cost_usd;
778
+ deps.onCost?.(totalCost);
779
+ }
780
+ }
781
+ deps.signal.removeEventListener("abort", onAbort);
782
+ return { exitCode: deps.signal.aborted ? 130 : 0, totalCostUsd: totalCost };
783
+ } catch (err) {
784
+ deps.signal.removeEventListener("abort", onAbort);
785
+ if (deps.signal.aborted) return { exitCode: 130, totalCostUsd: totalCost };
786
+ restarts++;
787
+ if (restarts > maxRestarts) {
788
+ deps.log?.(`[pushary] remote mode: too many restarts, giving up: ${errMsg(err)}`);
789
+ return { exitCode: 1, totalCostUsd: totalCost };
790
+ }
791
+ deps.log?.(
792
+ `[pushary] remote mode: transient error, resuming session (attempt ${restarts}/${maxRestarts}): ${errMsg(err)}`
793
+ );
794
+ await sleep(backoff(restarts));
795
+ }
796
+ }
797
+ return { exitCode: 130, totalCostUsd: totalCost };
798
+ };
799
+
800
+ // src/wrapper/args.ts
801
+ var resolveClaudeArgs = (argv) => {
802
+ const rest = argv.slice(2);
803
+ return rest[0] === "claude" ? rest.slice(1) : rest;
804
+ };
805
+ var extractRemoteFlag = (args2) => ({
806
+ remote: args2.includes("--remote"),
807
+ rest: args2.filter((arg) => arg !== "--remote")
808
+ });
809
+ var flagValue = (args2, index) => {
810
+ const inline = args2[index];
811
+ const eq = inline?.indexOf("=") ?? -1;
812
+ if (inline && eq > 0) return inline.slice(eq + 1);
813
+ const next = args2[index + 1];
814
+ return next && !next.startsWith("-") ? next : void 0;
815
+ };
816
+ var parseRemoteArgs = (args2) => {
817
+ let initialPrompt;
818
+ let resume;
819
+ let permissionMode;
820
+ for (let i = 0; i < args2.length; i++) {
821
+ const arg = args2[i];
822
+ if (arg === "-p" || arg === "--print" || arg?.startsWith("--print=")) {
823
+ initialPrompt = flagValue(args2, i);
824
+ } else if (arg === "-r" || arg === "--resume" || arg?.startsWith("--resume=")) {
825
+ resume = flagValue(args2, i);
826
+ } else if (arg === "--permission-mode" || arg?.startsWith("--permission-mode=")) {
827
+ permissionMode = flagValue(args2, i);
828
+ }
829
+ }
830
+ return { initialPrompt, resume, permissionMode };
123
831
  };
124
832
 
125
833
  // src/wrapper/remoteMode.ts
126
- var runRemoteMode = async (_binary, _args) => {
127
- return { implemented: false };
834
+ var INPUT_QUEUE_CAP = 32;
835
+ var extractAssistantText = (message) => {
836
+ const content = message.message?.content;
837
+ if (typeof content === "string") return content || void 0;
838
+ if (Array.isArray(content)) {
839
+ const text = content.filter((block) => !!block && typeof block === "object").filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("");
840
+ return text || void 0;
841
+ }
842
+ return void 0;
843
+ };
844
+ var streamTranscript = (relay, message) => {
845
+ if (message.type === "assistant") {
846
+ const text = extractAssistantText(message);
847
+ if (text) relay.sendTranscript("assistant", text);
848
+ } else if (message.type === "result") {
849
+ relay.sendTranscript(
850
+ "turn_end",
851
+ void 0,
852
+ typeof message.total_cost_usd === "number" ? { cost: message.total_cost_usd } : void 0
853
+ );
854
+ }
855
+ };
856
+ var runRemoteMode = async (_binary, args2) => {
857
+ let apiKey;
858
+ try {
859
+ apiKey = getApiKey();
860
+ } catch {
861
+ process.stderr.write(
862
+ "[pushary] remote mode needs an API key. Run `npx @pushary/agent-hooks setup`. Running the normal passthrough for now.\n"
863
+ );
864
+ return { implemented: false };
865
+ }
866
+ const sdk = await loadClaudeSdk();
867
+ if (!sdk) {
868
+ process.stderr.write(
869
+ "[pushary] remote mode needs the Claude Agent SDK, which is not installed. Enable it with:\n npm i -g @anthropic-ai/claude-agent-sdk@0.3.207\nRunning the normal passthrough for now.\n"
870
+ );
871
+ return { implemented: false };
872
+ }
873
+ const projectName = basename(process.cwd());
874
+ const machineId = getMachineId();
875
+ const controller = new AbortController();
876
+ let sessionId;
877
+ let killed = false;
878
+ let totalCostUsd = 0;
879
+ const input = new BoundedInputQueue({
880
+ cap: INPUT_QUEUE_CAP,
881
+ onDrop: (_dropped, total) => process.stderr.write(`[pushary] input queue full; dropped ${total} stale instruction(s)
882
+ `),
883
+ // Several instructions that piled up between turns are sent as one turn.
884
+ coalesce: (items) => userMessage(items.map((m) => m.message.content).join("\n\n"))
885
+ });
886
+ const approver = createRemoteApprover({
887
+ apiKey,
888
+ machineId,
889
+ projectName,
890
+ getSessionId: () => sessionId
891
+ });
892
+ const initialMode = await fetchModeState(apiKey).catch(() => null);
893
+ let relay;
894
+ if (initialMode?.relayUrl) {
895
+ relay = createRelayClient({
896
+ url: initialMode.relayUrl,
897
+ apiKey,
898
+ machineId,
899
+ agentType: "claude_code",
900
+ getSessionId: () => sessionId,
901
+ onCommand: (command) => input.push(userMessage(command)),
902
+ onFatal: (message) => process.stderr.write(`[pushary] relay unavailable (${message}); using polling
903
+ `),
904
+ log: (message) => process.stderr.write(`${message}
905
+ `)
906
+ });
907
+ relay.start();
908
+ }
909
+ const poller = startCommandPoller({
910
+ apiKey,
911
+ getSessionId: () => sessionId,
912
+ onCommand: (command) => input.push(userMessage(command)),
913
+ // While the relay socket owns command delivery, the poller must not also drain
914
+ // the single-use queue (that would deliver the command twice); it still reads
915
+ // mode/kill. When the socket is down or absent, the poller is the source.
916
+ shouldDrainCommands: () => !(relay?.connected() ?? false),
917
+ // Watch the kill switch: a phone "stop" interrupts the running turn (via the
918
+ // controller abort below), instead of only denying the next tool call.
919
+ fetchModeState,
920
+ onModeState: (state) => {
921
+ if (state.kill && !killed) {
922
+ killed = true;
923
+ process.stderr.write("[pushary] halted from Pushary \u2014 stopping the agent\n");
924
+ controller.abort();
925
+ }
926
+ },
927
+ log: (message) => process.stderr.write(`${message}
928
+ `)
929
+ });
930
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
931
+ const onSignal = () => controller.abort();
932
+ for (const signal of signals) process.on(signal, onSignal);
933
+ const { initialPrompt, resume, permissionMode } = parseRemoteArgs(args2);
934
+ try {
935
+ if (initialPrompt) input.push(userMessage(initialPrompt));
936
+ const result = await runRemoteLoop({
937
+ sdk,
938
+ input,
939
+ canUseTool: approver.canUseTool,
940
+ signal: controller.signal,
941
+ permissionMode,
942
+ initialResume: resume,
943
+ // The SDK REPLACES the subprocess env, so spread process.env and set the
944
+ // recursion guard so a wrapper-spawned claude cannot re-enter the wrapper.
945
+ env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
946
+ cwd: process.cwd(),
947
+ onSessionId: (id) => {
948
+ sessionId = id;
949
+ relay?.reannounce();
950
+ },
951
+ onCost: (usd) => {
952
+ totalCostUsd = usd;
953
+ },
954
+ // Stream a bounded live transcript to the phone (relay presence-gates it).
955
+ onMessage: relay ? (message) => streamTranscript(relay, message) : void 0,
956
+ log: (message) => process.stderr.write(`${message}
957
+ `)
958
+ });
959
+ return { implemented: true, exitCode: result.exitCode };
960
+ } finally {
961
+ for (const signal of signals) process.off(signal, onSignal);
962
+ relay?.stop();
963
+ poller.stop();
964
+ approver.teardown();
965
+ input.close();
966
+ if (totalCostUsd > 0) {
967
+ process.stderr.write(`[pushary] remote session cost: $${totalCostUsd.toFixed(4)}
968
+ `);
969
+ }
970
+ }
128
971
  };
129
972
 
130
973
  // src/wrapper/runClaudeWrapper.ts
@@ -137,31 +980,15 @@ var runClaudeWrapper = async (args2) => {
137
980
  return 127;
138
981
  }
139
982
  const nested = process.env[WRAPPER_ACTIVE_ENV] === "1";
140
- if (!nested && process.env.PUSHARY_WRAPPER_REMOTE === "1") {
983
+ const { remote: wantRemote, rest } = extractRemoteFlag(args2);
984
+ if (!nested && wantRemote) {
141
985
  try {
142
- const remote = await runRemoteMode(binary, args2);
986
+ const remote = await runRemoteMode(binary, rest);
143
987
  if (remote.implemented) return remote.exitCode ?? 0;
144
988
  } catch {
145
989
  }
146
990
  }
147
- const poller = nested ? { stop() {
148
- } } : startCommandPoller({
149
- onCommand: () => {
150
- },
151
- log: (m) => process.stderr.write(`${m}
152
- `)
153
- });
154
- try {
155
- return await runLocalPassthrough(binary, args2);
156
- } finally {
157
- poller.stop();
158
- }
159
- };
160
-
161
- // src/wrapper/args.ts
162
- var resolveClaudeArgs = (argv) => {
163
- const rest = argv.slice(2);
164
- return rest[0] === "claude" ? rest.slice(1) : rest;
991
+ return runLocalPassthrough(binary, rest);
165
992
  };
166
993
 
167
994
  // bin/pushary-claude.ts