@pushary/agent-hooks 0.44.0 → 0.49.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,4 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ needsShell,
4
+ spawnClaude
5
+ } from "../chunk-XHKBHWLX.js";
2
6
  import {
3
7
  isDeferAnswer
4
8
  } from "../chunk-KQYIHZ5E.js";
@@ -8,12 +12,14 @@ import {
8
12
  deriveToolTarget,
9
13
  describeToolCall,
10
14
  fetchModeState,
11
- getMachineId,
12
15
  getPolicy,
13
16
  reportEvent,
14
17
  resolvePolicy,
15
18
  waitForAnswer
16
- } from "../chunk-VZNP4R5I.js";
19
+ } from "../chunk-O5MFSRWV.js";
20
+ import {
21
+ getMachineId
22
+ } from "../chunk-RN3NOEJF.js";
17
23
  import "../chunk-DWED7BS3.js";
18
24
  import "../chunk-Z5PL3K7C.js";
19
25
  import {
@@ -47,16 +53,6 @@ var findClaudeBinary = () => {
47
53
  return null;
48
54
  };
49
55
 
50
- // src/wrapper/spawnClaude.ts
51
- import { spawn } from "child_process";
52
- var needsShell = (binary, platform = process.platform) => platform === "win32" && /\.(cmd|bat)$/i.test(binary);
53
- var spawnClaude = (binary, args2, options) => {
54
- if (needsShell(binary)) {
55
- return spawn(`"${binary}"`, args2, { ...options, shell: true });
56
- }
57
- return spawn(binary, args2, options);
58
- };
59
-
60
56
  // src/wrapper/localPassthrough.ts
61
57
  var SIGNAL_NUMBERS = {
62
58
  SIGHUP: 1,
@@ -101,11 +97,11 @@ var runLocalPassthrough = (binary, args2) => {
101
97
  });
102
98
  };
103
99
 
104
- // src/wrapper/remoteMode.ts
100
+ // src/wrapper/dualMode.ts
105
101
  import { basename } from "path";
106
102
 
107
103
  // src/wrapper/sdkLoader.ts
108
- import { spawn as spawn2 } from "child_process";
104
+ import { spawn } from "child_process";
109
105
  import { existsSync, mkdirSync, writeFileSync } from "fs";
110
106
  import { homedir } from "os";
111
107
  import { join as join2 } from "path";
@@ -151,10 +147,10 @@ var ensureClaudeSdk = async (log) => {
151
147
  } catch {
152
148
  return null;
153
149
  }
154
- log?.("[pushary] setting up remote mode (one-time, fetching the Claude Agent SDK)...");
150
+ log?.("[pushary] setting up phone control (one-time, fetching the Claude Agent SDK)...");
155
151
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
156
152
  const installed = await new Promise((resolve) => {
157
- const child = spawn2(
153
+ const child = spawn(
158
154
  npmCmd,
159
155
  ["install", "--no-optional", "--no-audit", "--no-fund", "--loglevel=error", SDK_SPEC],
160
156
  { cwd: dir, stdio: "ignore", shell: process.platform === "win32" }
@@ -225,6 +221,12 @@ var BoundedInputQueue = class {
225
221
  resolve({ value: void 0, done: true });
226
222
  }
227
223
  }
224
+ // Drop every buffered item without closing the queue. Used when phone control
225
+ // turns out to be unavailable (offline SDK setup) so a pending instruction does
226
+ // not make the local leg switch again into a mode that cannot run.
227
+ clear() {
228
+ this.buffer.length = 0;
229
+ }
228
230
  get isClosed() {
229
231
  return this.closed;
230
232
  }
@@ -330,6 +332,7 @@ var createRemoteApprover = (deps) => {
330
332
  }
331
333
  const controller = new AbortController();
332
334
  const entry = { controller };
335
+ let answered = false;
333
336
  pending.add(entry);
334
337
  const outerAbort = () => controller.abort();
335
338
  if (options?.signal) {
@@ -366,6 +369,7 @@ var createRemoteApprover = (deps) => {
366
369
  entry.correlationId = question.correlationId;
367
370
  if (question.noDevices) return applyTimeout(policy, input);
368
371
  const answer = await waitForPhone(entry, question.correlationId, windowFor(policy));
372
+ answered = answer.answered;
369
373
  if (answer.answered) {
370
374
  if (isDeferAnswer(answer.value)) return deny("Deferred \u2014 handle it on the machine");
371
375
  return answer.value === "yes" ? allow(input) : deny("Denied from your phone");
@@ -375,6 +379,10 @@ var createRemoteApprover = (deps) => {
375
379
  return deny("Approval unavailable; denied by default");
376
380
  } finally {
377
381
  if (options?.signal) options.signal.removeEventListener("abort", outerAbort);
382
+ if (entry.correlationId && !answered && !tornDown) {
383
+ void cancelQuestion2(deps.apiKey, entry.correlationId).catch(() => {
384
+ });
385
+ }
378
386
  pending.delete(entry);
379
387
  }
380
388
  };
@@ -465,10 +473,23 @@ var startCommandPoller = (opts) => {
465
473
  const timeout = setTimeout(() => controller.abort(), requestTimeout);
466
474
  try {
467
475
  const wantCommands = opts.shouldDrainCommands ? opts.shouldDrainCommands() : true;
468
- const [command] = await Promise.all([
469
- wantCommands ? drain(opts.apiKey, opts.getSessionId?.(), controller.signal) : Promise.resolve(null),
470
- fetchMode()
471
- ]);
476
+ let command;
477
+ if (opts.poll) {
478
+ const result = await opts.poll(opts.apiKey, opts.getSessionId?.(), wantCommands, controller.signal);
479
+ command = result.command;
480
+ if (result.mode && opts.onModeState) {
481
+ try {
482
+ opts.onModeState(result.mode);
483
+ } catch {
484
+ }
485
+ }
486
+ } else {
487
+ ;
488
+ [command] = await Promise.all([
489
+ wantCommands ? drain(opts.apiKey, opts.getSessionId?.(), controller.signal) : Promise.resolve(null),
490
+ fetchMode()
491
+ ]);
492
+ }
472
493
  errorStreak = 0;
473
494
  if (command) {
474
495
  idleTicks = 0;
@@ -499,19 +520,39 @@ var startCommandPoller = (opts) => {
499
520
  };
500
521
  };
501
522
 
523
+ // src/wrapper/pollClient.ts
524
+ var APPROVAL_MODES = /* @__PURE__ */ new Set(["push_only", "terminal_only", "push_first", "notify_only"]);
525
+ var pollAgent = async (apiKey, sessionId, wantCommand, signal, baseUrl = getBaseUrl()) => {
526
+ try {
527
+ const response = await fetch(`${baseUrl}/api/agent/poll`, {
528
+ method: "POST",
529
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
530
+ body: JSON.stringify({
531
+ ...sessionId ? { sessionId } : {},
532
+ ...wantCommand ? {} : { drain: false }
533
+ }),
534
+ signal
535
+ });
536
+ if (!response.ok) return { command: null, mode: null };
537
+ const data = await response.json();
538
+ const command = typeof data.command === "string" && data.command.length > 0 ? data.command : null;
539
+ const m = data.override?.mode;
540
+ const mode = {
541
+ mode: typeof m === "string" && APPROVAL_MODES.has(m) ? m : null,
542
+ kill: data.kill === true,
543
+ policyVersion: typeof data.policyVersion === "string" && data.policyVersion.length > 0 ? data.policyVersion : null,
544
+ relayUrl: typeof data.relayUrl === "string" && data.relayUrl.length > 0 ? data.relayUrl : null
545
+ };
546
+ return { command, mode };
547
+ } catch {
548
+ return { command: null, mode: null };
549
+ }
550
+ };
551
+
502
552
  // src/wrapper/wsProtocol.ts
503
553
  var PROTOCOL_VERSION = 1;
504
- var TRANSCRIPT_TEXT_MAX = 4e3;
505
554
  var encodeFrame = (frame) => JSON.stringify(frame);
506
555
  var helloFrame = (fields) => ({ v: PROTOCOL_VERSION, t: "hello", ...fields });
507
- var transcriptFrame = (seq, kind, text, meta) => ({
508
- v: PROTOCOL_VERSION,
509
- t: "transcript",
510
- seq,
511
- kind,
512
- ...text !== void 0 ? { text: text.slice(0, TRANSCRIPT_TEXT_MAX) } : {},
513
- ...meta ? { meta } : {}
514
- });
515
556
  var parse = (raw) => {
516
557
  try {
517
558
  const value = JSON.parse(raw);
@@ -524,21 +565,18 @@ var parse = (raw) => {
524
565
  }
525
566
  };
526
567
  var isString = (v) => typeof v === "string";
527
- var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
528
568
  var decodeServerFrame = (raw) => {
529
569
  const f = parse(raw);
530
570
  if (!f) return null;
531
571
  switch (f.t) {
532
572
  case "welcome":
533
- return isFiniteNumber(f.resumeFromSeq) ? { v: PROTOCOL_VERSION, t: "welcome", resumeFromSeq: f.resumeFromSeq } : null;
573
+ return { v: PROTOCOL_VERSION, t: "welcome" };
534
574
  case "ping":
535
575
  return { v: PROTOCOL_VERSION, t: "ping" };
536
576
  case "pong":
537
577
  return { v: PROTOCOL_VERSION, t: "pong" };
538
578
  case "command":
539
579
  return isString(f.id) && isString(f.text) ? { v: PROTOCOL_VERSION, t: "command", id: f.id, text: f.text } : null;
540
- case "resume":
541
- return isFiniteNumber(f.afterSeq) ? { v: PROTOCOL_VERSION, t: "resume", afterSeq: f.afterSeq } : null;
542
580
  case "error":
543
581
  if (isString(f.code) && isString(f.message)) {
544
582
  return { v: PROTOCOL_VERSION, t: "error", code: f.code, message: f.message, fatal: f.fatal === true };
@@ -552,7 +590,6 @@ var decodeServerFrame = (raw) => {
552
590
  // src/wrapper/relayClient.ts
553
591
  var DEFAULT_HEARTBEAT_MS = 2e4;
554
592
  var DEFAULT_PONG_TIMEOUT_MS = 1e4;
555
- var DEFAULT_MAX_OUTBOUND = 128;
556
593
  var DEFAULT_MAX_SEEN_COMMANDS = 256;
557
594
  var defaultBackoff = (attempt) => Math.min(1e3 * 2 ** attempt, 3e4);
558
595
  var defaultSocketFactory = (url) => {
@@ -573,17 +610,14 @@ var createRelayClient = (options) => {
573
610
  const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
574
611
  const pongTimeoutMs = options.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
575
612
  const backoff = options.backoffMs ?? defaultBackoff;
576
- const maxOutbound = Math.max(1, options.maxOutbound ?? DEFAULT_MAX_OUTBOUND);
577
613
  const maxSeenCommands = Math.max(1, options.maxSeenCommands ?? DEFAULT_MAX_SEEN_COMMANDS);
578
614
  let socket;
579
615
  let isConnected = false;
580
616
  let stopped = false;
581
- let seq = 0;
582
617
  let attempt = 0;
583
618
  let reconnectTimer;
584
619
  let heartbeatTimer;
585
620
  let pongTimer;
586
- const outbound = [];
587
621
  const seenCommands = [];
588
622
  const seenSet = /* @__PURE__ */ new Set();
589
623
  const log = (message) => options.log?.(message);
@@ -612,20 +646,14 @@ var createRelayClient = (options) => {
612
646
  } catch {
613
647
  }
614
648
  };
615
- const enqueueTranscript = (frame) => {
616
- if (isConnected) {
617
- sendFrame(frame);
618
- return;
619
- }
620
- outbound.push(frame);
621
- while (outbound.length > maxOutbound) outbound.shift();
622
- };
623
- const flushOutbound = () => {
624
- while (outbound.length > 0 && isConnected) {
625
- const frame = outbound.shift();
626
- if (frame) sendFrame(frame);
627
- }
628
- };
649
+ const sendHello = () => sendFrame(
650
+ helloFrame({
651
+ apiKey: options.apiKey,
652
+ machineId: options.machineId,
653
+ agentType: options.agentType,
654
+ sessionId: options.getSessionId()
655
+ })
656
+ );
629
657
  const armPong = () => {
630
658
  if (pongTimer) clearTimeout(pongTimer);
631
659
  pongTimer = setTimeout(() => {
@@ -646,7 +674,6 @@ var createRelayClient = (options) => {
646
674
  switch (frame.t) {
647
675
  case "welcome":
648
676
  attempt = 0;
649
- flushOutbound();
650
677
  log("[pushary] relay connected");
651
678
  break;
652
679
  case "command":
@@ -665,9 +692,6 @@ var createRelayClient = (options) => {
665
692
  if (pongTimer) clearTimeout(pongTimer);
666
693
  pongTimer = void 0;
667
694
  break;
668
- case "resume":
669
- log(`[pushary] relay signalled a gap; catch up after seq ${frame.afterSeq}`);
670
- break;
671
695
  case "error":
672
696
  if (frame.fatal) {
673
697
  log(`[pushary] relay fatal: ${frame.message}`);
@@ -725,15 +749,7 @@ var createRelayClient = (options) => {
725
749
  socket = created;
726
750
  created.onOpen(() => {
727
751
  isConnected = true;
728
- sendFrame(
729
- helloFrame({
730
- apiKey: options.apiKey,
731
- machineId: options.machineId,
732
- agentType: options.agentType,
733
- sessionId: options.getSessionId(),
734
- lastSeq: seq
735
- })
736
- );
752
+ sendHello();
737
753
  startHeartbeat();
738
754
  });
739
755
  created.onMessage((data) => onServerFrame(data));
@@ -752,22 +768,9 @@ var createRelayClient = (options) => {
752
768
  connected() {
753
769
  return isConnected;
754
770
  },
755
- sendTranscript(kind, text, meta) {
756
- if (stopped) return;
757
- seq++;
758
- enqueueTranscript(transcriptFrame(seq, kind, text, meta));
759
- },
760
771
  reannounce() {
761
772
  if (stopped || !isConnected) return;
762
- sendFrame(
763
- helloFrame({
764
- apiKey: options.apiKey,
765
- machineId: options.machineId,
766
- agentType: options.agentType,
767
- sessionId: options.getSessionId(),
768
- lastSeq: seq
769
- })
770
- );
773
+ sendHello();
771
774
  },
772
775
  stop() {
773
776
  if (stopped) return;
@@ -779,6 +782,166 @@ var createRelayClient = (options) => {
779
782
  };
780
783
  };
781
784
 
785
+ // src/wrapper/stdinHandoff.ts
786
+ var isTty = () => Boolean(process.stdin.isTTY);
787
+ var createStdinHandoff = () => {
788
+ let listener;
789
+ let captured = false;
790
+ const removeListener = () => {
791
+ if (listener) {
792
+ process.stdin.off("data", listener);
793
+ listener = void 0;
794
+ }
795
+ };
796
+ const setRaw = (on) => {
797
+ if (!isTty()) return;
798
+ try {
799
+ process.stdin.setRawMode(on);
800
+ } catch {
801
+ }
802
+ };
803
+ const captureForWrapper = (onKey) => {
804
+ removeListener();
805
+ listener = onKey;
806
+ if (!isTty()) return;
807
+ try {
808
+ process.stdin.resume();
809
+ setRaw(true);
810
+ process.stdin.setEncoding("utf8");
811
+ process.stdin.on("data", listener);
812
+ captured = true;
813
+ } catch {
814
+ removeListener();
815
+ }
816
+ };
817
+ const releaseToChild = () => {
818
+ removeListener();
819
+ setRaw(false);
820
+ if (captured && isTty()) {
821
+ try {
822
+ process.stdin.pause();
823
+ } catch {
824
+ }
825
+ }
826
+ captured = false;
827
+ };
828
+ return {
829
+ captureForWrapper,
830
+ releaseToChild,
831
+ dispose: releaseToChild
832
+ };
833
+ };
834
+
835
+ // src/wrapper/args.ts
836
+ var resolveClaudeArgs = (argv) => {
837
+ const rest = argv.slice(2);
838
+ return rest[0] === "claude" ? rest.slice(1) : rest;
839
+ };
840
+ var extractRemoteFlag = (args2) => ({
841
+ remote: args2.includes("--remote"),
842
+ rest: args2.filter((arg) => arg !== "--remote")
843
+ });
844
+ var flagValue = (args2, index) => {
845
+ const inline = args2[index];
846
+ const eq = inline?.indexOf("=") ?? -1;
847
+ if (inline && eq > 0) return inline.slice(eq + 1);
848
+ const next = args2[index + 1];
849
+ return next && !next.startsWith("-") ? next : void 0;
850
+ };
851
+ var parseRemoteArgs = (args2) => {
852
+ let initialPrompt;
853
+ let resume;
854
+ let permissionMode;
855
+ for (let i = 0; i < args2.length; i++) {
856
+ const arg = args2[i];
857
+ if (arg === "-p" || arg === "--print" || arg?.startsWith("--print=")) {
858
+ initialPrompt = flagValue(args2, i);
859
+ } else if (arg === "-r" || arg === "--resume" || arg?.startsWith("--resume=")) {
860
+ resume = flagValue(args2, i);
861
+ } else if (arg === "--permission-mode" || arg?.startsWith("--permission-mode=")) {
862
+ permissionMode = flagValue(args2, i);
863
+ }
864
+ }
865
+ return { initialPrompt, resume, permissionMode };
866
+ };
867
+
868
+ // src/wrapper/localLeg.ts
869
+ var SIGNAL_EXIT_BASE = 128;
870
+ var SIGNAL_NUMBERS2 = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
871
+ var nativeArgs = (session) => {
872
+ const args2 = [...session.claudeArgs];
873
+ const id = session.getSessionId();
874
+ if (id && !session.userManagesSession) {
875
+ args2.push(session.isSessionCreated() ? "--resume" : "--session-id", id);
876
+ }
877
+ return args2;
878
+ };
879
+ var runLocalLeg = (session, deps = {}) => {
880
+ const spawn2 = deps.spawn ?? spawnClaude;
881
+ return new Promise((resolve) => {
882
+ const legAbort = new AbortController();
883
+ let exitReason = null;
884
+ let settled = false;
885
+ const finish = () => {
886
+ if (settled) return;
887
+ settled = true;
888
+ session.setActiveLeg(null);
889
+ resolve(exitReason ?? { type: "exit", code: 0 });
890
+ };
891
+ const control = {
892
+ onPhoneCommand: () => {
893
+ if (!exitReason) exitReason = "switch";
894
+ if (!legAbort.signal.aborted) legAbort.abort();
895
+ },
896
+ stop: (reason) => {
897
+ if (!exitReason) exitReason = { type: "exit", code: reason === "signal" ? 130 : 0 };
898
+ if (!legAbort.signal.aborted) legAbort.abort();
899
+ }
900
+ };
901
+ session.setActiveLeg(control);
902
+ if (session.input.bufferedCount > 0) {
903
+ exitReason = "switch";
904
+ finish();
905
+ return;
906
+ }
907
+ session.stdin.releaseToChild();
908
+ let child;
909
+ try {
910
+ child = spawn2(session.binary, nativeArgs(session), {
911
+ stdio: "inherit",
912
+ env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
913
+ signal: legAbort.signal
914
+ });
915
+ } catch {
916
+ exitReason = exitReason ?? { type: "exit", code: 127 };
917
+ finish();
918
+ return;
919
+ }
920
+ session.markSessionCreated();
921
+ child.on("error", () => {
922
+ if (!legAbort.signal.aborted && !exitReason) {
923
+ exitReason = { type: "exit", code: 127 };
924
+ }
925
+ finish();
926
+ });
927
+ child.on("exit", (code, signal) => {
928
+ if (signal === "SIGTERM" && legAbort.signal.aborted) {
929
+ } else if (!exitReason) {
930
+ if (session.input.bufferedCount > 0) {
931
+ exitReason = "switch";
932
+ } else if (typeof code === "number") {
933
+ exitReason = { type: "exit", code };
934
+ } else if (signal) {
935
+ exitReason = { type: "exit", code: SIGNAL_EXIT_BASE + (SIGNAL_NUMBERS2[signal] ?? 0) };
936
+ } else {
937
+ exitReason = { type: "exit", code: 0 };
938
+ }
939
+ }
940
+ finish();
941
+ });
942
+ });
943
+ };
944
+
782
945
  // src/wrapper/remoteLoop.ts
783
946
  var errMsg = (err) => err instanceof Error ? err.message : String(err);
784
947
  var DEFAULT_MAX_RESTARTS = 5;
@@ -846,41 +1009,89 @@ var runRemoteLoop = async (deps) => {
846
1009
  return { exitCode: 130, totalCostUsd: totalCost };
847
1010
  };
848
1011
 
849
- // src/wrapper/args.ts
850
- var resolveClaudeArgs = (argv) => {
851
- const rest = argv.slice(2);
852
- return rest[0] === "claude" ? rest.slice(1) : rest;
853
- };
854
- var extractRemoteFlag = (args2) => ({
855
- remote: args2.includes("--remote"),
856
- rest: args2.filter((arg) => arg !== "--remote")
857
- });
858
- var flagValue = (args2, index) => {
859
- const inline = args2[index];
860
- const eq = inline?.indexOf("=") ?? -1;
861
- if (inline && eq > 0) return inline.slice(eq + 1);
862
- const next = args2[index + 1];
863
- return next && !next.startsWith("-") ? next : void 0;
1012
+ // src/wrapper/remoteLeg.ts
1013
+ var CTRL_RIGHT_BRACKET = "";
1014
+ var CTRL_C = "";
1015
+ var runRemoteLeg = async (session, sdk) => {
1016
+ const legAbort = new AbortController();
1017
+ let switchBack = false;
1018
+ const control = {
1019
+ // The running query already drains the shared input queue, so a freshly pushed
1020
+ // phone command is delivered without any action here.
1021
+ onPhoneCommand: () => {
1022
+ },
1023
+ stop: () => {
1024
+ if (!legAbort.signal.aborted) legAbort.abort();
1025
+ }
1026
+ };
1027
+ session.setActiveLeg(control);
1028
+ session.stdin.captureForWrapper((data) => {
1029
+ if (data.includes(CTRL_RIGHT_BRACKET)) {
1030
+ switchBack = true;
1031
+ if (!legAbort.signal.aborted) legAbort.abort();
1032
+ } else if (data.includes(CTRL_C)) {
1033
+ if (!legAbort.signal.aborted) legAbort.abort();
1034
+ }
1035
+ });
1036
+ process.stderr.write(
1037
+ "[pushary] remote \u2014 driving from your phone \xB7 press Ctrl-] for the local terminal\n"
1038
+ );
1039
+ let exitCode = 0;
1040
+ try {
1041
+ const result = await runRemoteLoop({
1042
+ sdk,
1043
+ input: session.input,
1044
+ canUseTool: session.approver.canUseTool,
1045
+ signal: legAbort.signal,
1046
+ // Resume the session the local leg (or a prior turn) created, so the
1047
+ // conversation is continuous across the switch. Undefined = fresh session.
1048
+ initialResume: session.getSessionId(),
1049
+ permissionMode: session.permissionMode,
1050
+ // The SDK REPLACES the subprocess env; spread process.env and mark the
1051
+ // recursion guard so a wrapper-spawned claude cannot re-enter the wrapper.
1052
+ env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
1053
+ cwd: process.cwd(),
1054
+ // Run the user's OWN claude (auth + version parity); Windows .cmd shims keep
1055
+ // the SDK's bundled binary since the SDK cannot spawn a shim directly.
1056
+ pathToClaudeCodeExecutable: needsShell(session.binary) ? void 0 : session.binary,
1057
+ onSessionId: (id) => session.setSessionId(id),
1058
+ onMessage: (message) => session.streamMessage(message),
1059
+ log: (message) => process.stderr.write(`${message}
1060
+ `)
1061
+ });
1062
+ exitCode = result.exitCode;
1063
+ } finally {
1064
+ session.stdin.releaseToChild();
1065
+ session.setActiveLeg(null);
1066
+ }
1067
+ return switchBack ? "switch" : { type: "exit", code: exitCode };
864
1068
  };
865
- var parseRemoteArgs = (args2) => {
866
- let initialPrompt;
867
- let resume;
868
- let permissionMode;
869
- for (let i = 0; i < args2.length; i++) {
870
- const arg = args2[i];
871
- if (arg === "-p" || arg === "--print" || arg?.startsWith("--print=")) {
872
- initialPrompt = flagValue(args2, i);
873
- } else if (arg === "-r" || arg === "--resume" || arg?.startsWith("--resume=")) {
874
- resume = flagValue(args2, i);
875
- } else if (arg === "--permission-mode" || arg?.startsWith("--permission-mode=")) {
876
- permissionMode = flagValue(args2, i);
1069
+
1070
+ // src/wrapper/modeLoop.ts
1071
+ var DEFAULT_MIN_DWELL_MS = 300;
1072
+ var driveModeLoop = async (deps) => {
1073
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1074
+ const now = deps.now ?? (() => Date.now());
1075
+ const minDwell = deps.minDwellMs ?? DEFAULT_MIN_DWELL_MS;
1076
+ let mode = deps.startingMode;
1077
+ let lastSwitchAt = 0;
1078
+ while (true) {
1079
+ const result = mode === "local" ? await deps.runLocal() : await deps.runRemote();
1080
+ if (result !== "switch" || deps.isFinished()) {
1081
+ return result === "switch" ? 0 : result.code;
877
1082
  }
1083
+ const since = now() - lastSwitchAt;
1084
+ if (since < minDwell) await sleep(minDwell - since);
1085
+ lastSwitchAt = now();
1086
+ mode = mode === "local" ? "remote" : "local";
878
1087
  }
879
- return { initialPrompt, resume, permissionMode };
880
1088
  };
881
1089
 
882
- // src/wrapper/remoteMode.ts
1090
+ // src/wrapper/dualMode.ts
883
1091
  var INPUT_QUEUE_CAP = 32;
1092
+ var MIN_MODE_DWELL_MS = 300;
1093
+ var RELAY_FALLBACK_POLL_MS = 6e4;
1094
+ var SESSION_FLAG_RE = /^(-r|--resume|-c|--continue|--session-id)(=.*)?$/;
884
1095
  var extractAssistantText = (message) => {
885
1096
  const content = message.message?.content;
886
1097
  if (typeof content === "string") return content || void 0;
@@ -890,69 +1101,50 @@ var extractAssistantText = (message) => {
890
1101
  }
891
1102
  return void 0;
892
1103
  };
893
- var echoLocal = (message) => {
894
- if (message.type === "assistant") {
895
- const text = extractAssistantText(message);
896
- if (text) process.stdout.write(`${text}
897
- `);
898
- } else if (message.type === "result") {
899
- process.stderr.write("[pushary] turn complete \u2014 standing by for phone commands (Ctrl-C to exit)\n");
900
- }
901
- };
902
- var streamTranscript = (relay, message) => {
903
- if (message.type === "assistant") {
904
- const text = extractAssistantText(message);
905
- if (text) relay.sendTranscript("assistant", text);
906
- } else if (message.type === "result") {
907
- relay.sendTranscript(
908
- "turn_end",
909
- void 0,
910
- typeof message.total_cost_usd === "number" ? { cost: message.total_cost_usd } : void 0
911
- );
912
- }
913
- };
914
- var runRemoteMode = async (binary, args2) => {
1104
+ var runDualMode = async (binary, args2, startingMode) => {
915
1105
  let apiKey;
916
1106
  try {
917
1107
  apiKey = getApiKey();
918
1108
  } catch {
919
1109
  process.stderr.write(
920
- "[pushary] remote mode needs an API key. Run `npx @pushary/agent-hooks setup`. Running the normal passthrough for now.\n"
1110
+ "[pushary] phone control needs an API key. Run `npx @pushary/agent-hooks setup`. Running the normal passthrough for now.\n"
921
1111
  );
922
1112
  return { implemented: false };
923
1113
  }
924
- const sdk = await ensureClaudeSdk((message) => process.stderr.write(`${message}
1114
+ let sdk;
1115
+ let remoteAvailable = true;
1116
+ let noticedUnavailable = false;
1117
+ const ensureSdk = async () => {
1118
+ if (sdk === void 0) {
1119
+ sdk = await ensureClaudeSdk((message) => process.stderr.write(`${message}
925
1120
  `));
926
- if (!sdk) {
927
- process.stderr.write("[pushary] remote mode is unavailable; running the normal passthrough.\n");
1121
+ if (!sdk) remoteAvailable = false;
1122
+ }
1123
+ return sdk;
1124
+ };
1125
+ if (startingMode === "remote" && !await ensureSdk()) {
1126
+ process.stderr.write("[pushary] phone control is unavailable; running the normal passthrough.\n");
928
1127
  return { implemented: false };
929
1128
  }
930
1129
  const projectName = basename(process.cwd());
1130
+ const agentName = `Claude Code - ${projectName}`;
931
1131
  const machineId = getMachineId();
932
- const controller = new AbortController();
933
- let sessionId;
934
- let killed = false;
1132
+ const stdin = createStdinHandoff();
1133
+ const { initialPrompt, resume, permissionMode } = parseRemoteArgs(args2);
1134
+ const userManagesSession = args2.some((a) => SESSION_FLAG_RE.test(a));
1135
+ let sessionId = resume;
1136
+ let sessionCreated = Boolean(resume);
1137
+ if (!sessionId && startingMode === "local" && !userManagesSession) {
1138
+ sessionId = crypto.randomUUID();
1139
+ }
935
1140
  let totalCostUsd = 0;
936
- const agentName = `Claude Code - ${projectName}`;
937
- const announcePresence = (event, action) => {
938
- if (!sessionId) return Promise.resolve();
939
- return reportEvent(
940
- { event, agentType: "claude_code", agentName, action, sessionId, machineId },
941
- { maxAttempts: 2, timeoutMs: 4e3 }
942
- ).then(() => {
943
- }).catch(() => {
944
- });
945
- };
946
- const presenceTimer = setInterval(
947
- () => void announcePresence("stop", "Standing by for phone commands"),
948
- 5 * 6e4
949
- );
950
- presenceTimer.unref?.();
1141
+ let killed = false;
1142
+ let finished = false;
1143
+ let activeLeg = null;
951
1144
  const input = new BoundedInputQueue({
952
1145
  cap: INPUT_QUEUE_CAP,
953
1146
  onDrop: (_dropped, total) => process.stderr.write(`[pushary] input queue full; dropped ${total} stale instruction(s)
954
1147
  `),
955
- // Several instructions that piled up between turns are sent as one turn.
956
1148
  coalesce: (items) => userMessage(items.map((m) => m.message.content).join("\n\n"))
957
1149
  });
958
1150
  const approver = createRemoteApprover({
@@ -961,16 +1153,17 @@ var runRemoteMode = async (binary, args2) => {
961
1153
  projectName,
962
1154
  getSessionId: () => sessionId
963
1155
  });
964
- const initialMode = await fetchModeState(apiKey).catch(() => null);
1156
+ const initialModeState = await fetchModeState(apiKey).catch(() => null);
1157
+ const relayAdvertised = Boolean(initialModeState?.relayUrl);
965
1158
  let relay;
966
- if (initialMode?.relayUrl) {
1159
+ if (initialModeState?.relayUrl) {
967
1160
  relay = createRelayClient({
968
- url: initialMode.relayUrl,
1161
+ url: initialModeState.relayUrl,
969
1162
  apiKey,
970
1163
  machineId,
971
1164
  agentType: "claude_code",
972
1165
  getSessionId: () => sessionId,
973
- onCommand: (command) => input.push(userMessage(command)),
1166
+ onCommand: (command) => onPhoneCommand(command),
974
1167
  onFatal: (message) => process.stderr.write(`[pushary] relay unavailable (${message}); using polling
975
1168
  `),
976
1169
  log: (message) => process.stderr.write(`${message}
@@ -978,84 +1171,144 @@ var runRemoteMode = async (binary, args2) => {
978
1171
  });
979
1172
  relay.start();
980
1173
  }
1174
+ const announcePresence = (event, action) => {
1175
+ if (!sessionId) return Promise.resolve();
1176
+ return reportEvent(
1177
+ { event, agentType: "claude_code", agentName, action, sessionId, machineId },
1178
+ { maxAttempts: 2, timeoutMs: 4e3 }
1179
+ ).then(() => {
1180
+ }).catch(() => {
1181
+ });
1182
+ };
1183
+ const setSessionId = (id) => {
1184
+ const isNew = sessionId !== id;
1185
+ sessionId = id;
1186
+ sessionCreated = true;
1187
+ relay?.reannounce();
1188
+ if (isNew) void announcePresence("session_start", "Remote session ready \u2014 reachable from your phone");
1189
+ };
1190
+ const onPhoneCommand = (command) => {
1191
+ if (!remoteAvailable) {
1192
+ if (!noticedUnavailable) {
1193
+ noticedUnavailable = true;
1194
+ process.stderr.write("[pushary] phone control is unavailable this run; the instruction was not delivered.\n");
1195
+ }
1196
+ return;
1197
+ }
1198
+ input.push(userMessage(command));
1199
+ activeLeg?.onPhoneCommand();
1200
+ };
1201
+ const streamMessage = (message) => {
1202
+ if (message.type === "assistant") {
1203
+ const text = extractAssistantText(message);
1204
+ if (text) process.stdout.write(`${text}
1205
+ `);
1206
+ } else if (message.type === "result") {
1207
+ if (typeof message.total_cost_usd === "number") totalCostUsd = message.total_cost_usd;
1208
+ process.stderr.write("[pushary] turn complete \u2014 standing by for phone commands\n");
1209
+ }
1210
+ };
981
1211
  const poller = startCommandPoller({
982
1212
  apiKey,
983
1213
  getSessionId: () => sessionId,
984
- onCommand: (command) => input.push(userMessage(command)),
985
- // While the relay socket owns command delivery, the poller must not also drain
986
- // the single-use queue (that would deliver the command twice); it still reads
987
- // mode/kill. When the socket is down or absent, the poller is the source.
1214
+ onCommand: (command) => onPhoneCommand(command),
988
1215
  shouldDrainCommands: () => !(relay?.connected() ?? false),
989
- // Watch the kill switch: a phone "stop" interrupts the running turn (via the
990
- // controller abort below), instead of only denying the next tool call.
1216
+ // Unified poll: one request per tick for both command + mode/kill (half the
1217
+ // requests and the per-poll DB write of the old drain + mode pair).
1218
+ poll: pollAgent,
991
1219
  fetchModeState,
1220
+ // Relay-primary: idle the safety-net poll at 60s (see RELAY_FALLBACK_POLL_MS).
1221
+ // No relay advertised -> the poller IS the command path, so keep the default.
1222
+ slowPollMs: relayAdvertised ? RELAY_FALLBACK_POLL_MS : void 0,
992
1223
  onModeState: (state) => {
993
1224
  if (state.kill && !killed) {
994
1225
  killed = true;
1226
+ finished = true;
995
1227
  process.stderr.write("[pushary] halted from Pushary \u2014 stopping the agent\n");
996
- controller.abort();
1228
+ activeLeg?.stop("kill");
997
1229
  }
998
1230
  },
999
1231
  log: (message) => process.stderr.write(`${message}
1000
1232
  `)
1001
1233
  });
1002
1234
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
1003
- const onSignal = () => controller.abort();
1235
+ const onSignal = () => {
1236
+ finished = true;
1237
+ activeLeg?.stop("signal");
1238
+ };
1004
1239
  for (const signal of signals) process.on(signal, onSignal);
1005
- const { initialPrompt, resume, permissionMode } = parseRemoteArgs(args2);
1240
+ const restoreStdin = () => stdin.dispose();
1241
+ process.once("exit", restoreStdin);
1242
+ const session = {
1243
+ apiKey,
1244
+ machineId,
1245
+ projectName,
1246
+ agentName,
1247
+ binary,
1248
+ input,
1249
+ approver,
1250
+ stdin,
1251
+ claudeArgs: args2,
1252
+ permissionMode,
1253
+ userManagesSession,
1254
+ getSessionId: () => sessionId,
1255
+ setSessionId,
1256
+ isSessionCreated: () => sessionCreated,
1257
+ markSessionCreated: () => {
1258
+ sessionCreated = true;
1259
+ },
1260
+ relayConnected: () => relay?.connected() ?? false,
1261
+ streamMessage,
1262
+ setActiveLeg: (control) => {
1263
+ activeLeg = control;
1264
+ }
1265
+ };
1006
1266
  try {
1007
- if (initialPrompt) input.push(userMessage(initialPrompt));
1008
- const result = await runRemoteLoop({
1009
- sdk,
1010
- input,
1011
- canUseTool: approver.canUseTool,
1012
- signal: controller.signal,
1013
- permissionMode,
1014
- initialResume: resume,
1015
- // The SDK REPLACES the subprocess env, so spread process.env and set the
1016
- // recursion guard so a wrapper-spawned claude cannot re-enter the wrapper.
1017
- env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
1018
- cwd: process.cwd(),
1019
- // Run the user's OWN claude, not the SDK's bundled copy, so whatever login
1020
- // already works for `claude` works here with no extra setup. On Windows a
1021
- // `.cmd`/`.bat` shim can't be spawned directly by the SDK, so there we let
1022
- // the SDK use its bundled binary (Windows reads creds from a plain file, so
1023
- // there is no keychain-ACL mismatch to work around).
1024
- pathToClaudeCodeExecutable: needsShell(binary) ? void 0 : binary,
1025
- onSessionId: (id) => {
1026
- sessionId = id;
1027
- relay?.reannounce();
1028
- void announcePresence("session_start", "Remote session ready \u2014 reachable from your phone");
1029
- },
1030
- onCost: (usd) => {
1031
- totalCostUsd = usd;
1032
- },
1033
- // Always echo the turn to the local terminal; also stream a bounded live
1034
- // transcript to the phone when the relay is up (it presence-gates it).
1035
- onMessage: (message) => {
1036
- echoLocal(message);
1037
- if (relay) streamTranscript(relay, message);
1267
+ if (sessionId && startingMode === "local") {
1268
+ void announcePresence("session_start", "Session ready \u2014 reachable from your phone");
1269
+ }
1270
+ if (startingMode === "remote" && initialPrompt) input.push(userMessage(initialPrompt));
1271
+ const exitCode = await driveModeLoop({
1272
+ startingMode,
1273
+ runLocal: () => runLocalLeg(session),
1274
+ runRemote: async () => {
1275
+ const ready = await ensureSdk();
1276
+ if (!ready) {
1277
+ remoteAvailable = false;
1278
+ input.clear();
1279
+ if (!noticedUnavailable) {
1280
+ noticedUnavailable = true;
1281
+ process.stderr.write(
1282
+ "[pushary] could not start phone control (offline?); staying in the terminal. Relaunch when online to enable.\n"
1283
+ );
1284
+ }
1285
+ return "switch";
1286
+ }
1287
+ return runRemoteLeg(session, ready);
1038
1288
  },
1039
- log: (message) => process.stderr.write(`${message}
1040
- `)
1289
+ isFinished: () => finished,
1290
+ minDwellMs: MIN_MODE_DWELL_MS
1041
1291
  });
1042
- return { implemented: true, exitCode: result.exitCode };
1292
+ return { implemented: true, exitCode };
1043
1293
  } finally {
1044
1294
  for (const signal of signals) process.off(signal, onSignal);
1045
- clearInterval(presenceTimer);
1295
+ process.removeListener("exit", restoreStdin);
1046
1296
  relay?.stop();
1047
1297
  poller.stop();
1048
1298
  approver.teardown();
1049
1299
  input.close();
1050
- await announcePresence("session_closed", "Remote session ended");
1300
+ stdin.dispose();
1301
+ await announcePresence("session_closed", "Session ended");
1051
1302
  if (totalCostUsd > 0) {
1052
- process.stderr.write(`[pushary] remote session cost: $${totalCostUsd.toFixed(4)}
1303
+ process.stderr.write(`[pushary] session cost: $${totalCostUsd.toFixed(4)}
1053
1304
  `);
1054
1305
  }
1055
1306
  }
1056
1307
  };
1057
1308
 
1058
1309
  // src/wrapper/runClaudeWrapper.ts
1310
+ var hasPrintFlag = (args2) => args2.some((a) => a === "-p" || a === "--print" || a.startsWith("--print="));
1311
+ var isInteractiveTty = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
1059
1312
  var runClaudeWrapper = async (args2) => {
1060
1313
  const binary = findClaudeBinary();
1061
1314
  if (!binary) {
@@ -1066,10 +1319,11 @@ var runClaudeWrapper = async (args2) => {
1066
1319
  }
1067
1320
  const nested = process.env[WRAPPER_ACTIVE_ENV] === "1";
1068
1321
  const { remote: wantRemote, rest } = extractRemoteFlag(args2);
1069
- if (!nested && wantRemote) {
1322
+ const useDual = !nested && (wantRemote || isInteractiveTty() && !hasPrintFlag(rest));
1323
+ if (useDual) {
1070
1324
  try {
1071
- const remote = await runRemoteMode(binary, rest);
1072
- if (remote.implemented) return remote.exitCode ?? 0;
1325
+ const result = await runDualMode(binary, rest, wantRemote ? "remote" : "local");
1326
+ if (result.implemented) return result.exitCode ?? 0;
1073
1327
  } catch {
1074
1328
  }
1075
1329
  }