@pushary/agent-hooks 0.44.0 → 0.48.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.
@@ -101,7 +101,7 @@ var runLocalPassthrough = (binary, args2) => {
101
101
  });
102
102
  };
103
103
 
104
- // src/wrapper/remoteMode.ts
104
+ // src/wrapper/dualMode.ts
105
105
  import { basename } from "path";
106
106
 
107
107
  // src/wrapper/sdkLoader.ts
@@ -151,7 +151,7 @@ var ensureClaudeSdk = async (log) => {
151
151
  } catch {
152
152
  return null;
153
153
  }
154
- log?.("[pushary] setting up remote mode (one-time, fetching the Claude Agent SDK)...");
154
+ log?.("[pushary] setting up phone control (one-time, fetching the Claude Agent SDK)...");
155
155
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
156
156
  const installed = await new Promise((resolve) => {
157
157
  const child = spawn2(
@@ -225,6 +225,12 @@ var BoundedInputQueue = class {
225
225
  resolve({ value: void 0, done: true });
226
226
  }
227
227
  }
228
+ // Drop every buffered item without closing the queue. Used when phone control
229
+ // turns out to be unavailable (offline SDK setup) so a pending instruction does
230
+ // not make the local leg switch again into a mode that cannot run.
231
+ clear() {
232
+ this.buffer.length = 0;
233
+ }
228
234
  get isClosed() {
229
235
  return this.closed;
230
236
  }
@@ -330,6 +336,7 @@ var createRemoteApprover = (deps) => {
330
336
  }
331
337
  const controller = new AbortController();
332
338
  const entry = { controller };
339
+ let answered = false;
333
340
  pending.add(entry);
334
341
  const outerAbort = () => controller.abort();
335
342
  if (options?.signal) {
@@ -366,6 +373,7 @@ var createRemoteApprover = (deps) => {
366
373
  entry.correlationId = question.correlationId;
367
374
  if (question.noDevices) return applyTimeout(policy, input);
368
375
  const answer = await waitForPhone(entry, question.correlationId, windowFor(policy));
376
+ answered = answer.answered;
369
377
  if (answer.answered) {
370
378
  if (isDeferAnswer(answer.value)) return deny("Deferred \u2014 handle it on the machine");
371
379
  return answer.value === "yes" ? allow(input) : deny("Denied from your phone");
@@ -375,6 +383,10 @@ var createRemoteApprover = (deps) => {
375
383
  return deny("Approval unavailable; denied by default");
376
384
  } finally {
377
385
  if (options?.signal) options.signal.removeEventListener("abort", outerAbort);
386
+ if (entry.correlationId && !answered && !tornDown) {
387
+ void cancelQuestion2(deps.apiKey, entry.correlationId).catch(() => {
388
+ });
389
+ }
378
390
  pending.delete(entry);
379
391
  }
380
392
  };
@@ -465,10 +477,23 @@ var startCommandPoller = (opts) => {
465
477
  const timeout = setTimeout(() => controller.abort(), requestTimeout);
466
478
  try {
467
479
  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
- ]);
480
+ let command;
481
+ if (opts.poll) {
482
+ const result = await opts.poll(opts.apiKey, opts.getSessionId?.(), wantCommands, controller.signal);
483
+ command = result.command;
484
+ if (result.mode && opts.onModeState) {
485
+ try {
486
+ opts.onModeState(result.mode);
487
+ } catch {
488
+ }
489
+ }
490
+ } else {
491
+ ;
492
+ [command] = await Promise.all([
493
+ wantCommands ? drain(opts.apiKey, opts.getSessionId?.(), controller.signal) : Promise.resolve(null),
494
+ fetchMode()
495
+ ]);
496
+ }
472
497
  errorStreak = 0;
473
498
  if (command) {
474
499
  idleTicks = 0;
@@ -499,19 +524,39 @@ var startCommandPoller = (opts) => {
499
524
  };
500
525
  };
501
526
 
527
+ // src/wrapper/pollClient.ts
528
+ var APPROVAL_MODES = /* @__PURE__ */ new Set(["push_only", "terminal_only", "push_first", "notify_only"]);
529
+ var pollAgent = async (apiKey, sessionId, wantCommand, signal, baseUrl = getBaseUrl()) => {
530
+ try {
531
+ const response = await fetch(`${baseUrl}/api/agent/poll`, {
532
+ method: "POST",
533
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
534
+ body: JSON.stringify({
535
+ ...sessionId ? { sessionId } : {},
536
+ ...wantCommand ? {} : { drain: false }
537
+ }),
538
+ signal
539
+ });
540
+ if (!response.ok) return { command: null, mode: null };
541
+ const data = await response.json();
542
+ const command = typeof data.command === "string" && data.command.length > 0 ? data.command : null;
543
+ const m = data.override?.mode;
544
+ const mode = {
545
+ mode: typeof m === "string" && APPROVAL_MODES.has(m) ? m : null,
546
+ kill: data.kill === true,
547
+ policyVersion: typeof data.policyVersion === "string" && data.policyVersion.length > 0 ? data.policyVersion : null,
548
+ relayUrl: typeof data.relayUrl === "string" && data.relayUrl.length > 0 ? data.relayUrl : null
549
+ };
550
+ return { command, mode };
551
+ } catch {
552
+ return { command: null, mode: null };
553
+ }
554
+ };
555
+
502
556
  // src/wrapper/wsProtocol.ts
503
557
  var PROTOCOL_VERSION = 1;
504
- var TRANSCRIPT_TEXT_MAX = 4e3;
505
558
  var encodeFrame = (frame) => JSON.stringify(frame);
506
559
  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
560
  var parse = (raw) => {
516
561
  try {
517
562
  const value = JSON.parse(raw);
@@ -524,21 +569,18 @@ var parse = (raw) => {
524
569
  }
525
570
  };
526
571
  var isString = (v) => typeof v === "string";
527
- var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
528
572
  var decodeServerFrame = (raw) => {
529
573
  const f = parse(raw);
530
574
  if (!f) return null;
531
575
  switch (f.t) {
532
576
  case "welcome":
533
- return isFiniteNumber(f.resumeFromSeq) ? { v: PROTOCOL_VERSION, t: "welcome", resumeFromSeq: f.resumeFromSeq } : null;
577
+ return { v: PROTOCOL_VERSION, t: "welcome" };
534
578
  case "ping":
535
579
  return { v: PROTOCOL_VERSION, t: "ping" };
536
580
  case "pong":
537
581
  return { v: PROTOCOL_VERSION, t: "pong" };
538
582
  case "command":
539
583
  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
584
  case "error":
543
585
  if (isString(f.code) && isString(f.message)) {
544
586
  return { v: PROTOCOL_VERSION, t: "error", code: f.code, message: f.message, fatal: f.fatal === true };
@@ -552,7 +594,6 @@ var decodeServerFrame = (raw) => {
552
594
  // src/wrapper/relayClient.ts
553
595
  var DEFAULT_HEARTBEAT_MS = 2e4;
554
596
  var DEFAULT_PONG_TIMEOUT_MS = 1e4;
555
- var DEFAULT_MAX_OUTBOUND = 128;
556
597
  var DEFAULT_MAX_SEEN_COMMANDS = 256;
557
598
  var defaultBackoff = (attempt) => Math.min(1e3 * 2 ** attempt, 3e4);
558
599
  var defaultSocketFactory = (url) => {
@@ -573,17 +614,14 @@ var createRelayClient = (options) => {
573
614
  const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
574
615
  const pongTimeoutMs = options.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
575
616
  const backoff = options.backoffMs ?? defaultBackoff;
576
- const maxOutbound = Math.max(1, options.maxOutbound ?? DEFAULT_MAX_OUTBOUND);
577
617
  const maxSeenCommands = Math.max(1, options.maxSeenCommands ?? DEFAULT_MAX_SEEN_COMMANDS);
578
618
  let socket;
579
619
  let isConnected = false;
580
620
  let stopped = false;
581
- let seq = 0;
582
621
  let attempt = 0;
583
622
  let reconnectTimer;
584
623
  let heartbeatTimer;
585
624
  let pongTimer;
586
- const outbound = [];
587
625
  const seenCommands = [];
588
626
  const seenSet = /* @__PURE__ */ new Set();
589
627
  const log = (message) => options.log?.(message);
@@ -612,20 +650,14 @@ var createRelayClient = (options) => {
612
650
  } catch {
613
651
  }
614
652
  };
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
- };
653
+ const sendHello = () => sendFrame(
654
+ helloFrame({
655
+ apiKey: options.apiKey,
656
+ machineId: options.machineId,
657
+ agentType: options.agentType,
658
+ sessionId: options.getSessionId()
659
+ })
660
+ );
629
661
  const armPong = () => {
630
662
  if (pongTimer) clearTimeout(pongTimer);
631
663
  pongTimer = setTimeout(() => {
@@ -646,7 +678,6 @@ var createRelayClient = (options) => {
646
678
  switch (frame.t) {
647
679
  case "welcome":
648
680
  attempt = 0;
649
- flushOutbound();
650
681
  log("[pushary] relay connected");
651
682
  break;
652
683
  case "command":
@@ -665,9 +696,6 @@ var createRelayClient = (options) => {
665
696
  if (pongTimer) clearTimeout(pongTimer);
666
697
  pongTimer = void 0;
667
698
  break;
668
- case "resume":
669
- log(`[pushary] relay signalled a gap; catch up after seq ${frame.afterSeq}`);
670
- break;
671
699
  case "error":
672
700
  if (frame.fatal) {
673
701
  log(`[pushary] relay fatal: ${frame.message}`);
@@ -725,15 +753,7 @@ var createRelayClient = (options) => {
725
753
  socket = created;
726
754
  created.onOpen(() => {
727
755
  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
- );
756
+ sendHello();
737
757
  startHeartbeat();
738
758
  });
739
759
  created.onMessage((data) => onServerFrame(data));
@@ -752,22 +772,9 @@ var createRelayClient = (options) => {
752
772
  connected() {
753
773
  return isConnected;
754
774
  },
755
- sendTranscript(kind, text, meta) {
756
- if (stopped) return;
757
- seq++;
758
- enqueueTranscript(transcriptFrame(seq, kind, text, meta));
759
- },
760
775
  reannounce() {
761
776
  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
- );
777
+ sendHello();
771
778
  },
772
779
  stop() {
773
780
  if (stopped) return;
@@ -779,6 +786,166 @@ var createRelayClient = (options) => {
779
786
  };
780
787
  };
781
788
 
789
+ // src/wrapper/stdinHandoff.ts
790
+ var isTty = () => Boolean(process.stdin.isTTY);
791
+ var createStdinHandoff = () => {
792
+ let listener;
793
+ let captured = false;
794
+ const removeListener = () => {
795
+ if (listener) {
796
+ process.stdin.off("data", listener);
797
+ listener = void 0;
798
+ }
799
+ };
800
+ const setRaw = (on) => {
801
+ if (!isTty()) return;
802
+ try {
803
+ process.stdin.setRawMode(on);
804
+ } catch {
805
+ }
806
+ };
807
+ const captureForWrapper = (onKey) => {
808
+ removeListener();
809
+ listener = onKey;
810
+ if (!isTty()) return;
811
+ try {
812
+ process.stdin.resume();
813
+ setRaw(true);
814
+ process.stdin.setEncoding("utf8");
815
+ process.stdin.on("data", listener);
816
+ captured = true;
817
+ } catch {
818
+ removeListener();
819
+ }
820
+ };
821
+ const releaseToChild = () => {
822
+ removeListener();
823
+ setRaw(false);
824
+ if (captured && isTty()) {
825
+ try {
826
+ process.stdin.pause();
827
+ } catch {
828
+ }
829
+ }
830
+ captured = false;
831
+ };
832
+ return {
833
+ captureForWrapper,
834
+ releaseToChild,
835
+ dispose: releaseToChild
836
+ };
837
+ };
838
+
839
+ // src/wrapper/args.ts
840
+ var resolveClaudeArgs = (argv) => {
841
+ const rest = argv.slice(2);
842
+ return rest[0] === "claude" ? rest.slice(1) : rest;
843
+ };
844
+ var extractRemoteFlag = (args2) => ({
845
+ remote: args2.includes("--remote"),
846
+ rest: args2.filter((arg) => arg !== "--remote")
847
+ });
848
+ var flagValue = (args2, index) => {
849
+ const inline = args2[index];
850
+ const eq = inline?.indexOf("=") ?? -1;
851
+ if (inline && eq > 0) return inline.slice(eq + 1);
852
+ const next = args2[index + 1];
853
+ return next && !next.startsWith("-") ? next : void 0;
854
+ };
855
+ var parseRemoteArgs = (args2) => {
856
+ let initialPrompt;
857
+ let resume;
858
+ let permissionMode;
859
+ for (let i = 0; i < args2.length; i++) {
860
+ const arg = args2[i];
861
+ if (arg === "-p" || arg === "--print" || arg?.startsWith("--print=")) {
862
+ initialPrompt = flagValue(args2, i);
863
+ } else if (arg === "-r" || arg === "--resume" || arg?.startsWith("--resume=")) {
864
+ resume = flagValue(args2, i);
865
+ } else if (arg === "--permission-mode" || arg?.startsWith("--permission-mode=")) {
866
+ permissionMode = flagValue(args2, i);
867
+ }
868
+ }
869
+ return { initialPrompt, resume, permissionMode };
870
+ };
871
+
872
+ // src/wrapper/localLeg.ts
873
+ var SIGNAL_EXIT_BASE = 128;
874
+ var SIGNAL_NUMBERS2 = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
875
+ var nativeArgs = (session) => {
876
+ const args2 = [...session.claudeArgs];
877
+ const id = session.getSessionId();
878
+ if (id && !session.userManagesSession) {
879
+ args2.push(session.isSessionCreated() ? "--resume" : "--session-id", id);
880
+ }
881
+ return args2;
882
+ };
883
+ var runLocalLeg = (session, deps = {}) => {
884
+ const spawn3 = deps.spawn ?? spawnClaude;
885
+ return new Promise((resolve) => {
886
+ const legAbort = new AbortController();
887
+ let exitReason = null;
888
+ let settled = false;
889
+ const finish = () => {
890
+ if (settled) return;
891
+ settled = true;
892
+ session.setActiveLeg(null);
893
+ resolve(exitReason ?? { type: "exit", code: 0 });
894
+ };
895
+ const control = {
896
+ onPhoneCommand: () => {
897
+ if (!exitReason) exitReason = "switch";
898
+ if (!legAbort.signal.aborted) legAbort.abort();
899
+ },
900
+ stop: (reason) => {
901
+ if (!exitReason) exitReason = { type: "exit", code: reason === "signal" ? 130 : 0 };
902
+ if (!legAbort.signal.aborted) legAbort.abort();
903
+ }
904
+ };
905
+ session.setActiveLeg(control);
906
+ if (session.input.bufferedCount > 0) {
907
+ exitReason = "switch";
908
+ finish();
909
+ return;
910
+ }
911
+ session.stdin.releaseToChild();
912
+ let child;
913
+ try {
914
+ child = spawn3(session.binary, nativeArgs(session), {
915
+ stdio: "inherit",
916
+ env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
917
+ signal: legAbort.signal
918
+ });
919
+ } catch {
920
+ exitReason = exitReason ?? { type: "exit", code: 127 };
921
+ finish();
922
+ return;
923
+ }
924
+ session.markSessionCreated();
925
+ child.on("error", () => {
926
+ if (!legAbort.signal.aborted && !exitReason) {
927
+ exitReason = { type: "exit", code: 127 };
928
+ }
929
+ finish();
930
+ });
931
+ child.on("exit", (code, signal) => {
932
+ if (signal === "SIGTERM" && legAbort.signal.aborted) {
933
+ } else if (!exitReason) {
934
+ if (session.input.bufferedCount > 0) {
935
+ exitReason = "switch";
936
+ } else if (typeof code === "number") {
937
+ exitReason = { type: "exit", code };
938
+ } else if (signal) {
939
+ exitReason = { type: "exit", code: SIGNAL_EXIT_BASE + (SIGNAL_NUMBERS2[signal] ?? 0) };
940
+ } else {
941
+ exitReason = { type: "exit", code: 0 };
942
+ }
943
+ }
944
+ finish();
945
+ });
946
+ });
947
+ };
948
+
782
949
  // src/wrapper/remoteLoop.ts
783
950
  var errMsg = (err) => err instanceof Error ? err.message : String(err);
784
951
  var DEFAULT_MAX_RESTARTS = 5;
@@ -846,41 +1013,89 @@ var runRemoteLoop = async (deps) => {
846
1013
  return { exitCode: 130, totalCostUsd: totalCost };
847
1014
  };
848
1015
 
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;
1016
+ // src/wrapper/remoteLeg.ts
1017
+ var CTRL_RIGHT_BRACKET = "";
1018
+ var CTRL_C = "";
1019
+ var runRemoteLeg = async (session, sdk) => {
1020
+ const legAbort = new AbortController();
1021
+ let switchBack = false;
1022
+ const control = {
1023
+ // The running query already drains the shared input queue, so a freshly pushed
1024
+ // phone command is delivered without any action here.
1025
+ onPhoneCommand: () => {
1026
+ },
1027
+ stop: () => {
1028
+ if (!legAbort.signal.aborted) legAbort.abort();
1029
+ }
1030
+ };
1031
+ session.setActiveLeg(control);
1032
+ session.stdin.captureForWrapper((data) => {
1033
+ if (data.includes(CTRL_RIGHT_BRACKET)) {
1034
+ switchBack = true;
1035
+ if (!legAbort.signal.aborted) legAbort.abort();
1036
+ } else if (data.includes(CTRL_C)) {
1037
+ if (!legAbort.signal.aborted) legAbort.abort();
1038
+ }
1039
+ });
1040
+ process.stderr.write(
1041
+ "[pushary] remote \u2014 driving from your phone \xB7 press Ctrl-] for the local terminal\n"
1042
+ );
1043
+ let exitCode = 0;
1044
+ try {
1045
+ const result = await runRemoteLoop({
1046
+ sdk,
1047
+ input: session.input,
1048
+ canUseTool: session.approver.canUseTool,
1049
+ signal: legAbort.signal,
1050
+ // Resume the session the local leg (or a prior turn) created, so the
1051
+ // conversation is continuous across the switch. Undefined = fresh session.
1052
+ initialResume: session.getSessionId(),
1053
+ permissionMode: session.permissionMode,
1054
+ // The SDK REPLACES the subprocess env; spread process.env and mark the
1055
+ // recursion guard so a wrapper-spawned claude cannot re-enter the wrapper.
1056
+ env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
1057
+ cwd: process.cwd(),
1058
+ // Run the user's OWN claude (auth + version parity); Windows .cmd shims keep
1059
+ // the SDK's bundled binary since the SDK cannot spawn a shim directly.
1060
+ pathToClaudeCodeExecutable: needsShell(session.binary) ? void 0 : session.binary,
1061
+ onSessionId: (id) => session.setSessionId(id),
1062
+ onMessage: (message) => session.streamMessage(message),
1063
+ log: (message) => process.stderr.write(`${message}
1064
+ `)
1065
+ });
1066
+ exitCode = result.exitCode;
1067
+ } finally {
1068
+ session.stdin.releaseToChild();
1069
+ session.setActiveLeg(null);
1070
+ }
1071
+ return switchBack ? "switch" : { type: "exit", code: exitCode };
864
1072
  };
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);
1073
+
1074
+ // src/wrapper/modeLoop.ts
1075
+ var DEFAULT_MIN_DWELL_MS = 300;
1076
+ var driveModeLoop = async (deps) => {
1077
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1078
+ const now = deps.now ?? (() => Date.now());
1079
+ const minDwell = deps.minDwellMs ?? DEFAULT_MIN_DWELL_MS;
1080
+ let mode = deps.startingMode;
1081
+ let lastSwitchAt = 0;
1082
+ while (true) {
1083
+ const result = mode === "local" ? await deps.runLocal() : await deps.runRemote();
1084
+ if (result !== "switch" || deps.isFinished()) {
1085
+ return result === "switch" ? 0 : result.code;
877
1086
  }
1087
+ const since = now() - lastSwitchAt;
1088
+ if (since < minDwell) await sleep(minDwell - since);
1089
+ lastSwitchAt = now();
1090
+ mode = mode === "local" ? "remote" : "local";
878
1091
  }
879
- return { initialPrompt, resume, permissionMode };
880
1092
  };
881
1093
 
882
- // src/wrapper/remoteMode.ts
1094
+ // src/wrapper/dualMode.ts
883
1095
  var INPUT_QUEUE_CAP = 32;
1096
+ var MIN_MODE_DWELL_MS = 300;
1097
+ var RELAY_FALLBACK_POLL_MS = 6e4;
1098
+ var SESSION_FLAG_RE = /^(-r|--resume|-c|--continue|--session-id)(=.*)?$/;
884
1099
  var extractAssistantText = (message) => {
885
1100
  const content = message.message?.content;
886
1101
  if (typeof content === "string") return content || void 0;
@@ -890,69 +1105,50 @@ var extractAssistantText = (message) => {
890
1105
  }
891
1106
  return void 0;
892
1107
  };
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) => {
1108
+ var runDualMode = async (binary, args2, startingMode) => {
915
1109
  let apiKey;
916
1110
  try {
917
1111
  apiKey = getApiKey();
918
1112
  } catch {
919
1113
  process.stderr.write(
920
- "[pushary] remote mode needs an API key. Run `npx @pushary/agent-hooks setup`. Running the normal passthrough for now.\n"
1114
+ "[pushary] phone control needs an API key. Run `npx @pushary/agent-hooks setup`. Running the normal passthrough for now.\n"
921
1115
  );
922
1116
  return { implemented: false };
923
1117
  }
924
- const sdk = await ensureClaudeSdk((message) => process.stderr.write(`${message}
1118
+ let sdk;
1119
+ let remoteAvailable = true;
1120
+ let noticedUnavailable = false;
1121
+ const ensureSdk = async () => {
1122
+ if (sdk === void 0) {
1123
+ sdk = await ensureClaudeSdk((message) => process.stderr.write(`${message}
925
1124
  `));
926
- if (!sdk) {
927
- process.stderr.write("[pushary] remote mode is unavailable; running the normal passthrough.\n");
1125
+ if (!sdk) remoteAvailable = false;
1126
+ }
1127
+ return sdk;
1128
+ };
1129
+ if (startingMode === "remote" && !await ensureSdk()) {
1130
+ process.stderr.write("[pushary] phone control is unavailable; running the normal passthrough.\n");
928
1131
  return { implemented: false };
929
1132
  }
930
1133
  const projectName = basename(process.cwd());
1134
+ const agentName = `Claude Code - ${projectName}`;
931
1135
  const machineId = getMachineId();
932
- const controller = new AbortController();
933
- let sessionId;
934
- let killed = false;
1136
+ const stdin = createStdinHandoff();
1137
+ const { initialPrompt, resume, permissionMode } = parseRemoteArgs(args2);
1138
+ const userManagesSession = args2.some((a) => SESSION_FLAG_RE.test(a));
1139
+ let sessionId = resume;
1140
+ let sessionCreated = Boolean(resume);
1141
+ if (!sessionId && startingMode === "local" && !userManagesSession) {
1142
+ sessionId = crypto.randomUUID();
1143
+ }
935
1144
  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?.();
1145
+ let killed = false;
1146
+ let finished = false;
1147
+ let activeLeg = null;
951
1148
  const input = new BoundedInputQueue({
952
1149
  cap: INPUT_QUEUE_CAP,
953
1150
  onDrop: (_dropped, total) => process.stderr.write(`[pushary] input queue full; dropped ${total} stale instruction(s)
954
1151
  `),
955
- // Several instructions that piled up between turns are sent as one turn.
956
1152
  coalesce: (items) => userMessage(items.map((m) => m.message.content).join("\n\n"))
957
1153
  });
958
1154
  const approver = createRemoteApprover({
@@ -961,16 +1157,17 @@ var runRemoteMode = async (binary, args2) => {
961
1157
  projectName,
962
1158
  getSessionId: () => sessionId
963
1159
  });
964
- const initialMode = await fetchModeState(apiKey).catch(() => null);
1160
+ const initialModeState = await fetchModeState(apiKey).catch(() => null);
1161
+ const relayAdvertised = Boolean(initialModeState?.relayUrl);
965
1162
  let relay;
966
- if (initialMode?.relayUrl) {
1163
+ if (initialModeState?.relayUrl) {
967
1164
  relay = createRelayClient({
968
- url: initialMode.relayUrl,
1165
+ url: initialModeState.relayUrl,
969
1166
  apiKey,
970
1167
  machineId,
971
1168
  agentType: "claude_code",
972
1169
  getSessionId: () => sessionId,
973
- onCommand: (command) => input.push(userMessage(command)),
1170
+ onCommand: (command) => onPhoneCommand(command),
974
1171
  onFatal: (message) => process.stderr.write(`[pushary] relay unavailable (${message}); using polling
975
1172
  `),
976
1173
  log: (message) => process.stderr.write(`${message}
@@ -978,84 +1175,144 @@ var runRemoteMode = async (binary, args2) => {
978
1175
  });
979
1176
  relay.start();
980
1177
  }
1178
+ const announcePresence = (event, action) => {
1179
+ if (!sessionId) return Promise.resolve();
1180
+ return reportEvent(
1181
+ { event, agentType: "claude_code", agentName, action, sessionId, machineId },
1182
+ { maxAttempts: 2, timeoutMs: 4e3 }
1183
+ ).then(() => {
1184
+ }).catch(() => {
1185
+ });
1186
+ };
1187
+ const setSessionId = (id) => {
1188
+ const isNew = sessionId !== id;
1189
+ sessionId = id;
1190
+ sessionCreated = true;
1191
+ relay?.reannounce();
1192
+ if (isNew) void announcePresence("session_start", "Remote session ready \u2014 reachable from your phone");
1193
+ };
1194
+ const onPhoneCommand = (command) => {
1195
+ if (!remoteAvailable) {
1196
+ if (!noticedUnavailable) {
1197
+ noticedUnavailable = true;
1198
+ process.stderr.write("[pushary] phone control is unavailable this run; the instruction was not delivered.\n");
1199
+ }
1200
+ return;
1201
+ }
1202
+ input.push(userMessage(command));
1203
+ activeLeg?.onPhoneCommand();
1204
+ };
1205
+ const streamMessage = (message) => {
1206
+ if (message.type === "assistant") {
1207
+ const text = extractAssistantText(message);
1208
+ if (text) process.stdout.write(`${text}
1209
+ `);
1210
+ } else if (message.type === "result") {
1211
+ if (typeof message.total_cost_usd === "number") totalCostUsd = message.total_cost_usd;
1212
+ process.stderr.write("[pushary] turn complete \u2014 standing by for phone commands\n");
1213
+ }
1214
+ };
981
1215
  const poller = startCommandPoller({
982
1216
  apiKey,
983
1217
  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.
1218
+ onCommand: (command) => onPhoneCommand(command),
988
1219
  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.
1220
+ // Unified poll: one request per tick for both command + mode/kill (half the
1221
+ // requests and the per-poll DB write of the old drain + mode pair).
1222
+ poll: pollAgent,
991
1223
  fetchModeState,
1224
+ // Relay-primary: idle the safety-net poll at 60s (see RELAY_FALLBACK_POLL_MS).
1225
+ // No relay advertised -> the poller IS the command path, so keep the default.
1226
+ slowPollMs: relayAdvertised ? RELAY_FALLBACK_POLL_MS : void 0,
992
1227
  onModeState: (state) => {
993
1228
  if (state.kill && !killed) {
994
1229
  killed = true;
1230
+ finished = true;
995
1231
  process.stderr.write("[pushary] halted from Pushary \u2014 stopping the agent\n");
996
- controller.abort();
1232
+ activeLeg?.stop("kill");
997
1233
  }
998
1234
  },
999
1235
  log: (message) => process.stderr.write(`${message}
1000
1236
  `)
1001
1237
  });
1002
1238
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
1003
- const onSignal = () => controller.abort();
1239
+ const onSignal = () => {
1240
+ finished = true;
1241
+ activeLeg?.stop("signal");
1242
+ };
1004
1243
  for (const signal of signals) process.on(signal, onSignal);
1005
- const { initialPrompt, resume, permissionMode } = parseRemoteArgs(args2);
1244
+ const restoreStdin = () => stdin.dispose();
1245
+ process.once("exit", restoreStdin);
1246
+ const session = {
1247
+ apiKey,
1248
+ machineId,
1249
+ projectName,
1250
+ agentName,
1251
+ binary,
1252
+ input,
1253
+ approver,
1254
+ stdin,
1255
+ claudeArgs: args2,
1256
+ permissionMode,
1257
+ userManagesSession,
1258
+ getSessionId: () => sessionId,
1259
+ setSessionId,
1260
+ isSessionCreated: () => sessionCreated,
1261
+ markSessionCreated: () => {
1262
+ sessionCreated = true;
1263
+ },
1264
+ relayConnected: () => relay?.connected() ?? false,
1265
+ streamMessage,
1266
+ setActiveLeg: (control) => {
1267
+ activeLeg = control;
1268
+ }
1269
+ };
1006
1270
  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);
1271
+ if (sessionId && startingMode === "local") {
1272
+ void announcePresence("session_start", "Session ready \u2014 reachable from your phone");
1273
+ }
1274
+ if (startingMode === "remote" && initialPrompt) input.push(userMessage(initialPrompt));
1275
+ const exitCode = await driveModeLoop({
1276
+ startingMode,
1277
+ runLocal: () => runLocalLeg(session),
1278
+ runRemote: async () => {
1279
+ const ready = await ensureSdk();
1280
+ if (!ready) {
1281
+ remoteAvailable = false;
1282
+ input.clear();
1283
+ if (!noticedUnavailable) {
1284
+ noticedUnavailable = true;
1285
+ process.stderr.write(
1286
+ "[pushary] could not start phone control (offline?); staying in the terminal. Relaunch when online to enable.\n"
1287
+ );
1288
+ }
1289
+ return "switch";
1290
+ }
1291
+ return runRemoteLeg(session, ready);
1038
1292
  },
1039
- log: (message) => process.stderr.write(`${message}
1040
- `)
1293
+ isFinished: () => finished,
1294
+ minDwellMs: MIN_MODE_DWELL_MS
1041
1295
  });
1042
- return { implemented: true, exitCode: result.exitCode };
1296
+ return { implemented: true, exitCode };
1043
1297
  } finally {
1044
1298
  for (const signal of signals) process.off(signal, onSignal);
1045
- clearInterval(presenceTimer);
1299
+ process.removeListener("exit", restoreStdin);
1046
1300
  relay?.stop();
1047
1301
  poller.stop();
1048
1302
  approver.teardown();
1049
1303
  input.close();
1050
- await announcePresence("session_closed", "Remote session ended");
1304
+ stdin.dispose();
1305
+ await announcePresence("session_closed", "Session ended");
1051
1306
  if (totalCostUsd > 0) {
1052
- process.stderr.write(`[pushary] remote session cost: $${totalCostUsd.toFixed(4)}
1307
+ process.stderr.write(`[pushary] session cost: $${totalCostUsd.toFixed(4)}
1053
1308
  `);
1054
1309
  }
1055
1310
  }
1056
1311
  };
1057
1312
 
1058
1313
  // src/wrapper/runClaudeWrapper.ts
1314
+ var hasPrintFlag = (args2) => args2.some((a) => a === "-p" || a === "--print" || a.startsWith("--print="));
1315
+ var isInteractiveTty = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
1059
1316
  var runClaudeWrapper = async (args2) => {
1060
1317
  const binary = findClaudeBinary();
1061
1318
  if (!binary) {
@@ -1066,10 +1323,11 @@ var runClaudeWrapper = async (args2) => {
1066
1323
  }
1067
1324
  const nested = process.env[WRAPPER_ACTIVE_ENV] === "1";
1068
1325
  const { remote: wantRemote, rest } = extractRemoteFlag(args2);
1069
- if (!nested && wantRemote) {
1326
+ const useDual = !nested && (wantRemote || isInteractiveTty() && !hasPrintFlag(rest));
1327
+ if (useDual) {
1070
1328
  try {
1071
- const remote = await runRemoteMode(binary, rest);
1072
- if (remote.implemented) return remote.exitCode ?? 0;
1329
+ const result = await runDualMode(binary, rest, wantRemote ? "remote" : "local");
1330
+ if (result.implemented) return result.exitCode ?? 0;
1073
1331
  } catch {
1074
1332
  }
1075
1333
  }
@@ -26,8 +26,9 @@ Pushary Agent Hooks
26
26
 
27
27
  Commands:
28
28
  setup Configure Claude Code, Codex, Gemini CLI, Hermes, or Cursor with Pushary
29
- claude Run Claude Code through Pushary (transparent passthrough; add --remote to
30
- drive it from your phone: reach and re-prompt it even while idle)
29
+ claude Run Claude Code through Pushary \u2014 the native terminal, and reachable from
30
+ your phone: send an instruction and it drives even a fully idle agent
31
+ (press Ctrl-] to take the terminal back). Add --remote to start headless.
31
32
  doctor Verify your Pushary installation is working
32
33
  clean Remove all Pushary configuration (--yes for non-interactive)
33
34
  mode Switch approval mode (push_only, push_first, terminal_only)
@@ -48,6 +49,7 @@ Usage:
48
49
  npx @pushary/agent-hooks@latest doctor
49
50
  npx @pushary/agent-hooks@latest mode push_only --for 30m
50
51
  npx @pushary/agent-hooks@latest wait 45
51
- pushary claude --remote -p "start the refactor" # drive from your phone (uses your existing Claude login; sets up on first run)
52
+ pushary claude # native terminal, reachable from your phone when idle
53
+ pushary claude --remote -p "start the refactor" # start headless, drive entirely from your phone
52
54
  `);
53
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushary/agent-hooks",
3
- "version": "0.44.0",
3
+ "version": "0.48.0",
4
4
  "description": "Permission hooks for AI coding agents: route tool approvals through Pushary push notifications",
5
5
  "keywords": [
6
6
  "pushary",
@@ -72,7 +72,7 @@
72
72
  "scripts": {
73
73
  "build": "node scripts/bundle-plugin.mjs && tsup",
74
74
  "dev": "tsup --watch",
75
- "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/config.test.ts && bun test src/mcp-http.test.ts && bun test src/retry.test.ts && bun test src/usage.test.ts && bun test src/validate.test.ts && bun test src/policy.test.ts && bun test src/npm.test.ts && bun test src/identity.test.ts && bun test src/pending.test.ts && bun test src/events.test.ts && bun test src/describe.test.ts && bun test src/reapply.test.ts && bun test src/suggestions.test.ts && bun test src/safe-commands.test.ts && bun test src/hook.test.ts && bun test src/codex-adapter.test.ts && bun test src/codex-config.test.ts && bun test src/gemini-adapter.test.ts && bun test src/gemini-config.test.ts && bun test src/instruction-file.test.ts && bun test src/pairing.test.ts && bun test src/wait-ladder.test.ts && bun test src/ledger.test.ts && bun test src/wrapper/wrapper.test.ts && bun test src/wrapper/messageQueue.test.ts && bun test src/wrapper/approver.test.ts && bun test src/wrapper/remoteLoop.test.ts && bun test src/wrapper/spawnClaude.test.ts && bun test src/wrapper/wsProtocol.test.ts && bun test src/wrapper/relayClient.test.ts"
75
+ "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/config.test.ts && bun test src/mcp-http.test.ts && bun test src/retry.test.ts && bun test src/usage.test.ts && bun test src/validate.test.ts && bun test src/policy.test.ts && bun test src/npm.test.ts && bun test src/identity.test.ts && bun test src/pending.test.ts && bun test src/events.test.ts && bun test src/describe.test.ts && bun test src/reapply.test.ts && bun test src/suggestions.test.ts && bun test src/safe-commands.test.ts && bun test src/hook.test.ts && bun test src/codex-adapter.test.ts && bun test src/codex-config.test.ts && bun test src/gemini-adapter.test.ts && bun test src/gemini-config.test.ts && bun test src/instruction-file.test.ts && bun test src/pairing.test.ts && bun test src/wait-ladder.test.ts && bun test src/ledger.test.ts && bun test src/wrapper/wrapper.test.ts && bun test src/wrapper/messageQueue.test.ts && bun test src/wrapper/approver.test.ts && bun test src/wrapper/remoteLoop.test.ts && bun test src/wrapper/spawnClaude.test.ts && bun test src/wrapper/wsProtocol.test.ts && bun test src/wrapper/relayClient.test.ts && bun test src/wrapper/modeLoop.test.ts && bun test src/wrapper/stdinHandoff.test.ts && bun test src/wrapper/localLeg.test.ts"
76
76
  },
77
77
  "dependencies": {
78
78
  "@inquirer/prompts": "^8.4.2",