@pushary/agent-hooks 0.43.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.
@@ -10,9 +10,10 @@ import {
10
10
  fetchModeState,
11
11
  getMachineId,
12
12
  getPolicy,
13
+ reportEvent,
13
14
  resolvePolicy,
14
15
  waitForAnswer
15
- } from "../chunk-ETDXSKR5.js";
16
+ } from "../chunk-VZNP4R5I.js";
16
17
  import "../chunk-DWED7BS3.js";
17
18
  import "../chunk-Z5PL3K7C.js";
18
19
  import {
@@ -100,7 +101,7 @@ var runLocalPassthrough = (binary, args2) => {
100
101
  });
101
102
  };
102
103
 
103
- // src/wrapper/remoteMode.ts
104
+ // src/wrapper/dualMode.ts
104
105
  import { basename } from "path";
105
106
 
106
107
  // src/wrapper/sdkLoader.ts
@@ -150,7 +151,7 @@ var ensureClaudeSdk = async (log) => {
150
151
  } catch {
151
152
  return null;
152
153
  }
153
- 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)...");
154
155
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
155
156
  const installed = await new Promise((resolve) => {
156
157
  const child = spawn2(
@@ -224,6 +225,12 @@ var BoundedInputQueue = class {
224
225
  resolve({ value: void 0, done: true });
225
226
  }
226
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
+ }
227
234
  get isClosed() {
228
235
  return this.closed;
229
236
  }
@@ -329,6 +336,7 @@ var createRemoteApprover = (deps) => {
329
336
  }
330
337
  const controller = new AbortController();
331
338
  const entry = { controller };
339
+ let answered = false;
332
340
  pending.add(entry);
333
341
  const outerAbort = () => controller.abort();
334
342
  if (options?.signal) {
@@ -365,6 +373,7 @@ var createRemoteApprover = (deps) => {
365
373
  entry.correlationId = question.correlationId;
366
374
  if (question.noDevices) return applyTimeout(policy, input);
367
375
  const answer = await waitForPhone(entry, question.correlationId, windowFor(policy));
376
+ answered = answer.answered;
368
377
  if (answer.answered) {
369
378
  if (isDeferAnswer(answer.value)) return deny("Deferred \u2014 handle it on the machine");
370
379
  return answer.value === "yes" ? allow(input) : deny("Denied from your phone");
@@ -374,6 +383,10 @@ var createRemoteApprover = (deps) => {
374
383
  return deny("Approval unavailable; denied by default");
375
384
  } finally {
376
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
+ }
377
390
  pending.delete(entry);
378
391
  }
379
392
  };
@@ -464,10 +477,23 @@ var startCommandPoller = (opts) => {
464
477
  const timeout = setTimeout(() => controller.abort(), requestTimeout);
465
478
  try {
466
479
  const wantCommands = opts.shouldDrainCommands ? opts.shouldDrainCommands() : true;
467
- const [command] = await Promise.all([
468
- wantCommands ? drain(opts.apiKey, opts.getSessionId?.(), controller.signal) : Promise.resolve(null),
469
- fetchMode()
470
- ]);
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
+ }
471
497
  errorStreak = 0;
472
498
  if (command) {
473
499
  idleTicks = 0;
@@ -498,19 +524,39 @@ var startCommandPoller = (opts) => {
498
524
  };
499
525
  };
500
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
+
501
556
  // src/wrapper/wsProtocol.ts
502
557
  var PROTOCOL_VERSION = 1;
503
- var TRANSCRIPT_TEXT_MAX = 4e3;
504
558
  var encodeFrame = (frame) => JSON.stringify(frame);
505
559
  var helloFrame = (fields) => ({ v: PROTOCOL_VERSION, t: "hello", ...fields });
506
- var transcriptFrame = (seq, kind, text, meta) => ({
507
- v: PROTOCOL_VERSION,
508
- t: "transcript",
509
- seq,
510
- kind,
511
- ...text !== void 0 ? { text: text.slice(0, TRANSCRIPT_TEXT_MAX) } : {},
512
- ...meta ? { meta } : {}
513
- });
514
560
  var parse = (raw) => {
515
561
  try {
516
562
  const value = JSON.parse(raw);
@@ -523,21 +569,18 @@ var parse = (raw) => {
523
569
  }
524
570
  };
525
571
  var isString = (v) => typeof v === "string";
526
- var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
527
572
  var decodeServerFrame = (raw) => {
528
573
  const f = parse(raw);
529
574
  if (!f) return null;
530
575
  switch (f.t) {
531
576
  case "welcome":
532
- return isFiniteNumber(f.resumeFromSeq) ? { v: PROTOCOL_VERSION, t: "welcome", resumeFromSeq: f.resumeFromSeq } : null;
577
+ return { v: PROTOCOL_VERSION, t: "welcome" };
533
578
  case "ping":
534
579
  return { v: PROTOCOL_VERSION, t: "ping" };
535
580
  case "pong":
536
581
  return { v: PROTOCOL_VERSION, t: "pong" };
537
582
  case "command":
538
583
  return isString(f.id) && isString(f.text) ? { v: PROTOCOL_VERSION, t: "command", id: f.id, text: f.text } : null;
539
- case "resume":
540
- return isFiniteNumber(f.afterSeq) ? { v: PROTOCOL_VERSION, t: "resume", afterSeq: f.afterSeq } : null;
541
584
  case "error":
542
585
  if (isString(f.code) && isString(f.message)) {
543
586
  return { v: PROTOCOL_VERSION, t: "error", code: f.code, message: f.message, fatal: f.fatal === true };
@@ -551,7 +594,6 @@ var decodeServerFrame = (raw) => {
551
594
  // src/wrapper/relayClient.ts
552
595
  var DEFAULT_HEARTBEAT_MS = 2e4;
553
596
  var DEFAULT_PONG_TIMEOUT_MS = 1e4;
554
- var DEFAULT_MAX_OUTBOUND = 128;
555
597
  var DEFAULT_MAX_SEEN_COMMANDS = 256;
556
598
  var defaultBackoff = (attempt) => Math.min(1e3 * 2 ** attempt, 3e4);
557
599
  var defaultSocketFactory = (url) => {
@@ -572,17 +614,14 @@ var createRelayClient = (options) => {
572
614
  const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
573
615
  const pongTimeoutMs = options.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
574
616
  const backoff = options.backoffMs ?? defaultBackoff;
575
- const maxOutbound = Math.max(1, options.maxOutbound ?? DEFAULT_MAX_OUTBOUND);
576
617
  const maxSeenCommands = Math.max(1, options.maxSeenCommands ?? DEFAULT_MAX_SEEN_COMMANDS);
577
618
  let socket;
578
619
  let isConnected = false;
579
620
  let stopped = false;
580
- let seq = 0;
581
621
  let attempt = 0;
582
622
  let reconnectTimer;
583
623
  let heartbeatTimer;
584
624
  let pongTimer;
585
- const outbound = [];
586
625
  const seenCommands = [];
587
626
  const seenSet = /* @__PURE__ */ new Set();
588
627
  const log = (message) => options.log?.(message);
@@ -611,20 +650,14 @@ var createRelayClient = (options) => {
611
650
  } catch {
612
651
  }
613
652
  };
614
- const enqueueTranscript = (frame) => {
615
- if (isConnected) {
616
- sendFrame(frame);
617
- return;
618
- }
619
- outbound.push(frame);
620
- while (outbound.length > maxOutbound) outbound.shift();
621
- };
622
- const flushOutbound = () => {
623
- while (outbound.length > 0 && isConnected) {
624
- const frame = outbound.shift();
625
- if (frame) sendFrame(frame);
626
- }
627
- };
653
+ const sendHello = () => sendFrame(
654
+ helloFrame({
655
+ apiKey: options.apiKey,
656
+ machineId: options.machineId,
657
+ agentType: options.agentType,
658
+ sessionId: options.getSessionId()
659
+ })
660
+ );
628
661
  const armPong = () => {
629
662
  if (pongTimer) clearTimeout(pongTimer);
630
663
  pongTimer = setTimeout(() => {
@@ -645,7 +678,6 @@ var createRelayClient = (options) => {
645
678
  switch (frame.t) {
646
679
  case "welcome":
647
680
  attempt = 0;
648
- flushOutbound();
649
681
  log("[pushary] relay connected");
650
682
  break;
651
683
  case "command":
@@ -664,9 +696,6 @@ var createRelayClient = (options) => {
664
696
  if (pongTimer) clearTimeout(pongTimer);
665
697
  pongTimer = void 0;
666
698
  break;
667
- case "resume":
668
- log(`[pushary] relay signalled a gap; catch up after seq ${frame.afterSeq}`);
669
- break;
670
699
  case "error":
671
700
  if (frame.fatal) {
672
701
  log(`[pushary] relay fatal: ${frame.message}`);
@@ -724,15 +753,7 @@ var createRelayClient = (options) => {
724
753
  socket = created;
725
754
  created.onOpen(() => {
726
755
  isConnected = true;
727
- sendFrame(
728
- helloFrame({
729
- apiKey: options.apiKey,
730
- machineId: options.machineId,
731
- agentType: options.agentType,
732
- sessionId: options.getSessionId(),
733
- lastSeq: seq
734
- })
735
- );
756
+ sendHello();
736
757
  startHeartbeat();
737
758
  });
738
759
  created.onMessage((data) => onServerFrame(data));
@@ -751,22 +772,9 @@ var createRelayClient = (options) => {
751
772
  connected() {
752
773
  return isConnected;
753
774
  },
754
- sendTranscript(kind, text, meta) {
755
- if (stopped) return;
756
- seq++;
757
- enqueueTranscript(transcriptFrame(seq, kind, text, meta));
758
- },
759
775
  reannounce() {
760
776
  if (stopped || !isConnected) return;
761
- sendFrame(
762
- helloFrame({
763
- apiKey: options.apiKey,
764
- machineId: options.machineId,
765
- agentType: options.agentType,
766
- sessionId: options.getSessionId(),
767
- lastSeq: seq
768
- })
769
- );
777
+ sendHello();
770
778
  },
771
779
  stop() {
772
780
  if (stopped) return;
@@ -778,6 +786,166 @@ var createRelayClient = (options) => {
778
786
  };
779
787
  };
780
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
+
781
949
  // src/wrapper/remoteLoop.ts
782
950
  var errMsg = (err) => err instanceof Error ? err.message : String(err);
783
951
  var DEFAULT_MAX_RESTARTS = 5;
@@ -845,41 +1013,89 @@ var runRemoteLoop = async (deps) => {
845
1013
  return { exitCode: 130, totalCostUsd: totalCost };
846
1014
  };
847
1015
 
848
- // src/wrapper/args.ts
849
- var resolveClaudeArgs = (argv) => {
850
- const rest = argv.slice(2);
851
- return rest[0] === "claude" ? rest.slice(1) : rest;
852
- };
853
- var extractRemoteFlag = (args2) => ({
854
- remote: args2.includes("--remote"),
855
- rest: args2.filter((arg) => arg !== "--remote")
856
- });
857
- var flagValue = (args2, index) => {
858
- const inline = args2[index];
859
- const eq = inline?.indexOf("=") ?? -1;
860
- if (inline && eq > 0) return inline.slice(eq + 1);
861
- const next = args2[index + 1];
862
- 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 };
863
1072
  };
864
- var parseRemoteArgs = (args2) => {
865
- let initialPrompt;
866
- let resume;
867
- let permissionMode;
868
- for (let i = 0; i < args2.length; i++) {
869
- const arg = args2[i];
870
- if (arg === "-p" || arg === "--print" || arg?.startsWith("--print=")) {
871
- initialPrompt = flagValue(args2, i);
872
- } else if (arg === "-r" || arg === "--resume" || arg?.startsWith("--resume=")) {
873
- resume = flagValue(args2, i);
874
- } else if (arg === "--permission-mode" || arg?.startsWith("--permission-mode=")) {
875
- 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;
876
1086
  }
1087
+ const since = now() - lastSwitchAt;
1088
+ if (since < minDwell) await sleep(minDwell - since);
1089
+ lastSwitchAt = now();
1090
+ mode = mode === "local" ? "remote" : "local";
877
1091
  }
878
- return { initialPrompt, resume, permissionMode };
879
1092
  };
880
1093
 
881
- // src/wrapper/remoteMode.ts
1094
+ // src/wrapper/dualMode.ts
882
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)(=.*)?$/;
883
1099
  var extractAssistantText = (message) => {
884
1100
  const content = message.message?.content;
885
1101
  if (typeof content === "string") return content || void 0;
@@ -889,54 +1105,50 @@ var extractAssistantText = (message) => {
889
1105
  }
890
1106
  return void 0;
891
1107
  };
892
- var echoLocal = (message) => {
893
- if (message.type === "assistant") {
894
- const text = extractAssistantText(message);
895
- if (text) process.stdout.write(`${text}
896
- `);
897
- } else if (message.type === "result") {
898
- process.stderr.write("[pushary] turn complete \u2014 standing by for phone commands (Ctrl-C to exit)\n");
899
- }
900
- };
901
- var streamTranscript = (relay, message) => {
902
- if (message.type === "assistant") {
903
- const text = extractAssistantText(message);
904
- if (text) relay.sendTranscript("assistant", text);
905
- } else if (message.type === "result") {
906
- relay.sendTranscript(
907
- "turn_end",
908
- void 0,
909
- typeof message.total_cost_usd === "number" ? { cost: message.total_cost_usd } : void 0
910
- );
911
- }
912
- };
913
- var runRemoteMode = async (binary, args2) => {
1108
+ var runDualMode = async (binary, args2, startingMode) => {
914
1109
  let apiKey;
915
1110
  try {
916
1111
  apiKey = getApiKey();
917
1112
  } catch {
918
1113
  process.stderr.write(
919
- "[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"
920
1115
  );
921
1116
  return { implemented: false };
922
1117
  }
923
- 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}
924
1124
  `));
925
- if (!sdk) {
926
- 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");
927
1131
  return { implemented: false };
928
1132
  }
929
1133
  const projectName = basename(process.cwd());
1134
+ const agentName = `Claude Code - ${projectName}`;
930
1135
  const machineId = getMachineId();
931
- const controller = new AbortController();
932
- let sessionId;
933
- 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
+ }
934
1144
  let totalCostUsd = 0;
1145
+ let killed = false;
1146
+ let finished = false;
1147
+ let activeLeg = null;
935
1148
  const input = new BoundedInputQueue({
936
1149
  cap: INPUT_QUEUE_CAP,
937
1150
  onDrop: (_dropped, total) => process.stderr.write(`[pushary] input queue full; dropped ${total} stale instruction(s)
938
1151
  `),
939
- // Several instructions that piled up between turns are sent as one turn.
940
1152
  coalesce: (items) => userMessage(items.map((m) => m.message.content).join("\n\n"))
941
1153
  });
942
1154
  const approver = createRemoteApprover({
@@ -945,16 +1157,17 @@ var runRemoteMode = async (binary, args2) => {
945
1157
  projectName,
946
1158
  getSessionId: () => sessionId
947
1159
  });
948
- const initialMode = await fetchModeState(apiKey).catch(() => null);
1160
+ const initialModeState = await fetchModeState(apiKey).catch(() => null);
1161
+ const relayAdvertised = Boolean(initialModeState?.relayUrl);
949
1162
  let relay;
950
- if (initialMode?.relayUrl) {
1163
+ if (initialModeState?.relayUrl) {
951
1164
  relay = createRelayClient({
952
- url: initialMode.relayUrl,
1165
+ url: initialModeState.relayUrl,
953
1166
  apiKey,
954
1167
  machineId,
955
1168
  agentType: "claude_code",
956
1169
  getSessionId: () => sessionId,
957
- onCommand: (command) => input.push(userMessage(command)),
1170
+ onCommand: (command) => onPhoneCommand(command),
958
1171
  onFatal: (message) => process.stderr.write(`[pushary] relay unavailable (${message}); using polling
959
1172
  `),
960
1173
  log: (message) => process.stderr.write(`${message}
@@ -962,81 +1175,144 @@ var runRemoteMode = async (binary, args2) => {
962
1175
  });
963
1176
  relay.start();
964
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
+ };
965
1215
  const poller = startCommandPoller({
966
1216
  apiKey,
967
1217
  getSessionId: () => sessionId,
968
- onCommand: (command) => input.push(userMessage(command)),
969
- // While the relay socket owns command delivery, the poller must not also drain
970
- // the single-use queue (that would deliver the command twice); it still reads
971
- // mode/kill. When the socket is down or absent, the poller is the source.
1218
+ onCommand: (command) => onPhoneCommand(command),
972
1219
  shouldDrainCommands: () => !(relay?.connected() ?? false),
973
- // Watch the kill switch: a phone "stop" interrupts the running turn (via the
974
- // 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,
975
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,
976
1227
  onModeState: (state) => {
977
1228
  if (state.kill && !killed) {
978
1229
  killed = true;
1230
+ finished = true;
979
1231
  process.stderr.write("[pushary] halted from Pushary \u2014 stopping the agent\n");
980
- controller.abort();
1232
+ activeLeg?.stop("kill");
981
1233
  }
982
1234
  },
983
1235
  log: (message) => process.stderr.write(`${message}
984
1236
  `)
985
1237
  });
986
1238
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
987
- const onSignal = () => controller.abort();
1239
+ const onSignal = () => {
1240
+ finished = true;
1241
+ activeLeg?.stop("signal");
1242
+ };
988
1243
  for (const signal of signals) process.on(signal, onSignal);
989
- 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
+ };
990
1270
  try {
991
- if (initialPrompt) input.push(userMessage(initialPrompt));
992
- const result = await runRemoteLoop({
993
- sdk,
994
- input,
995
- canUseTool: approver.canUseTool,
996
- signal: controller.signal,
997
- permissionMode,
998
- initialResume: resume,
999
- // The SDK REPLACES the subprocess env, so spread process.env and set the
1000
- // recursion guard so a wrapper-spawned claude cannot re-enter the wrapper.
1001
- env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
1002
- cwd: process.cwd(),
1003
- // Run the user's OWN claude, not the SDK's bundled copy, so whatever login
1004
- // already works for `claude` works here with no extra setup. On Windows a
1005
- // `.cmd`/`.bat` shim can't be spawned directly by the SDK, so there we let
1006
- // the SDK use its bundled binary (Windows reads creds from a plain file, so
1007
- // there is no keychain-ACL mismatch to work around).
1008
- pathToClaudeCodeExecutable: needsShell(binary) ? void 0 : binary,
1009
- onSessionId: (id) => {
1010
- sessionId = id;
1011
- relay?.reannounce();
1012
- },
1013
- onCost: (usd) => {
1014
- totalCostUsd = usd;
1015
- },
1016
- // Always echo the turn to the local terminal; also stream a bounded live
1017
- // transcript to the phone when the relay is up (it presence-gates it).
1018
- onMessage: (message) => {
1019
- echoLocal(message);
1020
- 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);
1021
1292
  },
1022
- log: (message) => process.stderr.write(`${message}
1023
- `)
1293
+ isFinished: () => finished,
1294
+ minDwellMs: MIN_MODE_DWELL_MS
1024
1295
  });
1025
- return { implemented: true, exitCode: result.exitCode };
1296
+ return { implemented: true, exitCode };
1026
1297
  } finally {
1027
1298
  for (const signal of signals) process.off(signal, onSignal);
1299
+ process.removeListener("exit", restoreStdin);
1028
1300
  relay?.stop();
1029
1301
  poller.stop();
1030
1302
  approver.teardown();
1031
1303
  input.close();
1304
+ stdin.dispose();
1305
+ await announcePresence("session_closed", "Session ended");
1032
1306
  if (totalCostUsd > 0) {
1033
- process.stderr.write(`[pushary] remote session cost: $${totalCostUsd.toFixed(4)}
1307
+ process.stderr.write(`[pushary] session cost: $${totalCostUsd.toFixed(4)}
1034
1308
  `);
1035
1309
  }
1036
1310
  }
1037
1311
  };
1038
1312
 
1039
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);
1040
1316
  var runClaudeWrapper = async (args2) => {
1041
1317
  const binary = findClaudeBinary();
1042
1318
  if (!binary) {
@@ -1047,10 +1323,11 @@ var runClaudeWrapper = async (args2) => {
1047
1323
  }
1048
1324
  const nested = process.env[WRAPPER_ACTIVE_ENV] === "1";
1049
1325
  const { remote: wantRemote, rest } = extractRemoteFlag(args2);
1050
- if (!nested && wantRemote) {
1326
+ const useDual = !nested && (wantRemote || isInteractiveTty() && !hasPrintFlag(rest));
1327
+ if (useDual) {
1051
1328
  try {
1052
- const remote = await runRemoteMode(binary, rest);
1053
- 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;
1054
1331
  } catch {
1055
1332
  }
1056
1333
  }