@@ -3649,6 +3649,39 @@ func cmdSendMessage(args []string) {
36493649 }
36503650 msgType := flagString (flags , "type" , "text" )
36513651
3652+ // --reply-on-conn opts this request into reply-on-connection: send it as a
3653+ // TypeAutoAnswer frame and read the reply the receiver writes back on the
3654+ // SAME connection (into the inbox), instead of relying on a dial-back.
3655+ //
3656+ // ALWAYS SAFE — never worse than a plain send. Against an --auto-answer agent
3657+ // the reply rides back on this connection (so it works even when this sender
3658+ // is NAT'd / has no public port / is transient). Against ANY other agent it
3659+ // transparently falls back to a normal dial-back: an updated agent saved the
3660+ // request, and an old/stock agent (which acks the frame as UNKNOWN) triggers
3661+ // an automatic resend as plain TEXT — either way the reply is dial-backed
3662+ // exactly as for a normal send. Current senders that don't set it are
3663+ // untouched. Env: PILOT_REPLY_ON_CONN=1.
3664+ replyOnConn := flagBool (flags , "reply-on-conn" ) || os .Getenv ("PILOT_REPLY_ON_CONN" ) != ""
3665+ if replyOnConn {
3666+ // reply-on-connection carries the request as a text query, so true binary
3667+ // would be lossily url-escaped — reject it rather than diverge silently
3668+ // from a plain --type binary send.
3669+ if msgType == "binary" {
3670+ fatalCode ("invalid_argument" , "--reply-on-conn does not support --type binary" )
3671+ }
3672+ // --trace sends a TypeTrace frame for timing and is incompatible with
3673+ // reply-on-connection; trace wins, so disable reply-on-conn handling to
3674+ // avoid a spurious fallback resend (double inbox entry).
3675+ if traceTime {
3676+ replyOnConn = false
3677+ }
3678+ // An --auto-answer receiver closes after one request+reply, so a reused
3679+ // connection can't carry a second send. Dial fresh per send instead.
3680+ if reuseConn {
3681+ reuseConn = false
3682+ }
3683+ }
3684+
36523685 // Auto-handshake to peers in the embedded trusted-agents list.
36533686 // Best-effort: warns on stderr and continues if handshake fails.
36543687 maybeAutoHandshake (d , target , flagBool (flags , "no-auto-handshake" ))
@@ -3687,12 +3720,16 @@ func cmdSendMessage(args []string) {
36873720 sentAtNs , sendErr = cl .SendTrace (innerType , []byte (data ))
36883721 } else {
36893722 sendStart := time .Now ()
3690- switch msgType {
3691- case "text" :
3723+ switch {
3724+ case replyOnConn :
3725+ // Opt in to reply-on-connection (TypeAutoAnswer); the reply is
3726+ // read off this connection below and saved to the inbox.
3727+ sendErr = cl .SendAutoAnswer (data )
3728+ case msgType == "text" :
36923729 sendErr = cl .SendText (data )
3693- case "json" :
3730+ case msgType == "json" :
36943731 sendErr = cl .SendJSON ([]byte (data ))
3695- case "binary" :
3732+ case msgType == "binary" :
36963733 sendErr = cl .SendBinary ([]byte (data ))
36973734 }
36983735 sentAtNs = sendStart .UnixNano ()
@@ -3713,7 +3750,62 @@ func cmdSendMessage(args []string) {
37133750 "reused" : reused ,
37143751 }
37153752 if ack != nil {
3716- r ["ack" ] = string (ack .Payload )
3753+ ackStr := string (ack .Payload )
3754+ r ["ack" ] = ackStr
3755+ // reply-on-connection delivery + GUARANTEED fallback. With
3756+ // --reply-on-conn we sent a TypeAutoAnswer request; decide whether a
3757+ // reply is (or will be) delivered, and if not, fall back so the flag
3758+ // is ALWAYS at least as good as a plain send:
3759+ // * ACK+REPLY → an --auto-answer agent's reply follows on
3760+ // THIS connection; read it into the inbox.
3761+ // * ack names the AUTOANSWER type → an updated, non-auto-answering
3762+ // agent saved the request; it will dial back.
3763+ // * anything else (an OLD/stock daemon acks UNKNOWN and does NOT
3764+ // save it; or the on-connection read failed)
3765+ // → resend as plain TEXT so ANY daemon saves
3766+ // it and the responder dial-backs the reply.
3767+ if replyOnConn {
3768+ delivered := false
3769+ switch replyOnConnOutcome (ackStr ) {
3770+ case replyOnConnRead :
3771+ // Read window is tied to the receiver's autoAnswerWindow (40s)
3772+ // plus margin — NOT to --wait. --wait bounds the later inbox
3773+ // poll; capping the on-connection read by a small --wait would
3774+ // abandon a reply the receiver has already committed to send
3775+ // (it skipped the inbox) and force a needless resend.
3776+ _ = cl .SetReadDeadline (time .Now ().Add (45 * time .Second ))
3777+ reply , rerr := cl .Recv ()
3778+ _ = cl .SetReadDeadline (time.Time {})
3779+ if rerr == nil && reply != nil && len (reply .Payload ) > 0 {
3780+ if p , serr := saveReplyToInbox (target .String (), reply .Type , reply .Payload ); serr == nil {
3781+ r ["reply_bytes" ] = len (reply .Payload )
3782+ r ["reply_inbox" ] = p
3783+ delivered = true
3784+ } else {
3785+ r ["reply_error" ] = serr .Error ()
3786+ }
3787+ } else if rerr != nil {
3788+ r ["reply_error" ] = rerr .Error ()
3789+ }
3790+ case replyOnConnDelivered :
3791+ // Understood by an updated daemon and saved for dial-back.
3792+ delivered = true
3793+ }
3794+ if ! delivered {
3795+ // Old/stock receiver (or a lost on-connection reply): resend
3796+ // as a plain TEXT message — exactly the pre-feature path — so
3797+ // the receiver saves it and the reply is dial-backed. This is
3798+ // what makes --reply-on-conn never worse than a plain send.
3799+ if rc , derr := dataexchange .Dial (d , target ); derr == nil {
3800+ _ = rc .SendText (data )
3801+ _ , _ = rc .Recv () // consume ack
3802+ _ = rc .Close ()
3803+ r ["fallback" ] = "dialback"
3804+ } else {
3805+ r ["fallback_error" ] = derr .Error ()
3806+ }
3807+ }
3808+ }
37173809 }
37183810 if traceTime {
37193811 r ["total_ms" ] = float64 (time .Duration (ackRecvAtNs - sentAtNs ).Microseconds ()) / 1000.0
@@ -5273,9 +5365,70 @@ func cmdReceived(args []string) {
52735365 fmt .Printf ("\n total: %d\n " , len (files ))
52745366}
52755367
5368+ // reply-on-connection sender outcomes, decided from the receiver's ack.
5369+ const (
5370+ replyOnConnRead = "read" // --auto-answer agent: read the reply on this connection
5371+ replyOnConnDelivered = "delivered" // updated agent understood + saved it: a dial-back is coming
5372+ replyOnConnResend = "resend" // old/stock agent did not understand it: resend as plain TEXT
5373+ )
5374+
5375+ // replyOnConnOutcome decides what a --reply-on-conn sender must do, from the
5376+ // receiver's ack payload. This is the core of the "always safe" guarantee: only
5377+ // an "ACK+REPLY" ack carries a reply on the connection; only a daemon that knows
5378+ // the AUTOANSWER type echoes it in the ack (so it understood and saved the
5379+ // request for dial-back); everything else is an old/stock daemon that acked the
5380+ // frame as UNKNOWN without saving it, so the sender must resend as plain TEXT.
5381+ func replyOnConnOutcome (ackPayload string ) string {
5382+ switch {
5383+ case strings .HasPrefix (ackPayload , "ACK+REPLY" ):
5384+ return replyOnConnRead
5385+ case strings .Contains (ackPayload , dataexchange .TypeName (dataexchange .TypeAutoAnswer )):
5386+ return replyOnConnDelivered
5387+ default :
5388+ return replyOnConnResend
5389+ }
5390+ }
5391+
5392+ // saveReplyToInbox writes a reply-on-connection frame to ~/.pilot/inbox/ in the
5393+ // exact shape the daemon's dataexchange service uses, so it is indistinguishable
5394+ // from a dial-back reply to `pilotctl inbox` and any inbox tooling. Returns the
5395+ // written path. This is how a reply-aware sender lands the answer in its own
5396+ // inbox without ever being dialed back.
5397+ func saveReplyToInbox (from string , frameType uint32 , payload []byte ) (string , error ) {
5398+ home , err := os .UserHomeDir ()
5399+ if err != nil {
5400+ return "" , err
5401+ }
5402+ dir := filepath .Join (home , ".pilot" , "inbox" )
5403+ if err := os .MkdirAll (dir , 0700 ); err != nil {
5404+ return "" , err
5405+ }
5406+ ts := time .Now ()
5407+ msg := map [string ]interface {}{
5408+ "type" : dataexchange .TypeName (frameType ),
5409+ "from" : from ,
5410+ "data" : string (payload ),
5411+ "bytes" : len (payload ),
5412+ "received_at" : ts .Format (time .RFC3339Nano ),
5413+ }
5414+ data , err := json .Marshal (msg )
5415+ if err != nil {
5416+ return "" , err
5417+ }
5418+ // Nanosecond stamp + pid keep the filename unique without the daemon's
5419+ // shared atomic sequence counter.
5420+ filename := fmt .Sprintf ("%s-%s-%06d.json" , dataexchange .TypeName (frameType ),
5421+ ts .Format ("20060102-150405.000000000" ), os .Getpid ()% 1000000 )
5422+ destPath := filepath .Join (dir , filename )
5423+ if err := os .WriteFile (destPath , data , 0600 ); err != nil {
5424+ return "" , err
5425+ }
5426+ return destPath , nil
5427+ }
5428+
52765429// cmdInbox lists or clears messages received via data exchange (port 1001).
52775430// waitForInboxReply polls ~/.pilot/inbox/ until a JSON file arrives that is
5278- // newer than cutoff and (if agentHint is non-empty) has a matching "agent "
5431+ // newer than cutoff and (if agentHint is non-empty) has a matching "from "
52795432// field. Returns the parsed message or an error on timeout.
52805433func waitForInboxReply (agentHint string , cutoff time.Time , timeout time.Duration ) (map [string ]interface {}, error ) {
52815434 home , err := os .UserHomeDir ()
0 commit comments