@@ -46,7 +46,9 @@ import {
4646 getGraphLogEntries ,
4747 getNodesData ,
4848 getEdgesData ,
49- getDbNode
49+ getDbNode ,
50+ getActiveHarnessPresence ,
51+ isHarnessPresenceFresh
5052} from './queries.mjs' ;
5153import { applyHarnessMetadataDefaults } from '../../scripts/lifecycle/harnessRouting.mjs' ;
5254import { getDefaultInstancePid , getInstancePid } from './instanceResolver.mjs' ;
@@ -676,6 +678,7 @@ let deliveryPromise = Promise.resolve();
676678 */
677679async function deliverDigest ( subscription , digest ) {
678680 const meta = subscription . properties ?. harnessTargetMetadata || { } ;
681+ const { instanceAddress, addressType} = resolveInstanceAddress ( meta ) ;
679682 // Fall back to osascript on macOS by default, tmux otherwise
680683 const defaultAdapter = process . platform === 'darwin' ? 'osascript' : 'tmux' ;
681684 const adapter = meta . adapter || defaultAdapter ;
@@ -684,8 +687,19 @@ async function deliverDigest(subscription, digest) {
684687 deliveryPromise = deliveryPromise . then ( async ( ) => {
685688
686689 try {
690+ if ( addressType && instanceAddress && ! assertFreshTargetPresence ( subscription , { addressType} ) ) {
691+ return ;
692+ }
693+
694+ if ( addressType === 'webhookUrl' ) {
695+ await deliverViaWebhookUrl ( subscription , digest , instanceAddress ) ;
696+ return ;
697+ }
698+
687699 if ( adapter === 'tmux' ) {
688- const tmuxSession = meta . tmuxSession || process . env . TMUX_SESSION || 'neo-agent' ;
700+ const tmuxSession = addressType === 'tmuxSession' && instanceAddress
701+ ? instanceAddress
702+ : meta . tmuxSession || process . env . TMUX_SESSION || 'neo-agent' ;
689703 await spawnAsync ( 'tmux' , [ 'send-keys' , '-t' , tmuxSession , digest , 'C-m' ] ) ;
690704 writeLog ( 'INFO' , `[Bridge Daemon] Delivered ${ subscription . id } via tmux to session ${ tmuxSession } ` ) ;
691705 } else if ( adapter === 'osascript' ) {
@@ -722,11 +736,10 @@ async function deliverDigest(subscription, digest) {
722736 return ;
723737 }
724738
725- // Instance-addressable wake — LOCAL deployment only. When the subscription carries a
726- // userDataDir, two same-bundle GUI harnesses may be running; resolve the intended
727- // instance's pid and raise THAT process — never the ambiguous app-activate / frontmost
728- // guess. Fail closed (skip the wake) if the instance cannot be located, so a targeted
729- // wake never lands in the wrong one.
739+ // Instance-addressable wake — LOCAL deployment only. When the subscription carries an
740+ // addressType, route through that address instead of falling back to ambiguous
741+ // app-activate/frontmost guessing. Fail closed (skip the wake) if the instance cannot
742+ // be located, so a targeted wake never lands in the wrong one.
730743 //
731744 // Instance addressing is a local-only primitive: this daemon delivers desktop-harness
732745 // wakes via osascript/tmux, which a headless cloud deployment has no GUI harness to
@@ -736,7 +749,24 @@ async function deliverDigest(subscription, digest) {
736749 // app-activate — a targeted wake must never silently degrade to an untargeted one. Uses
737750 // the canonical AiConfig.orchestrator.deploymentMode signal.
738751 let instancePid = null ;
739- if ( meta . userDataDir ) {
752+ if ( addressType === 'pid' ) {
753+ if ( AiConfig . orchestrator . deploymentMode === 'cloud' ) {
754+ writeLog ( 'ERROR' ,
755+ `[Bridge Daemon] Instance wake refused for ${ subscription . id } : ` +
756+ `pid targeting requires local deployment (deploymentMode='cloud'). ` +
757+ `Failing closed — instance-addressed GUI wakes are local-only (ADR 0014).`
758+ ) ;
759+ return ;
760+ }
761+ instancePid = normalizePid ( instanceAddress ) ;
762+ if ( ! instancePid ) {
763+ writeLog ( 'ERROR' ,
764+ `[Bridge Daemon] Instance wake refused for ${ subscription . id } : ` +
765+ `invalid pid instanceAddress='${ instanceAddress } '. Failing closed.`
766+ ) ;
767+ return ;
768+ }
769+ } else if ( addressType === 'userDataDir' && instanceAddress ) {
740770 if ( AiConfig . orchestrator . deploymentMode === 'cloud' ) {
741771 writeLog ( 'ERROR' ,
742772 `[Bridge Daemon] Instance wake refused for ${ subscription . id } : ` +
@@ -745,11 +775,11 @@ async function deliverDigest(subscription, digest) {
745775 ) ;
746776 return ;
747777 }
748- instancePid = await getInstancePid ( { userDataDir : meta . userDataDir } ) ;
778+ instancePid = await getInstancePid ( { userDataDir : instanceAddress } ) ;
749779 if ( ! instancePid ) {
750780 writeLog ( 'ERROR' ,
751781 `[Bridge Daemon] Instance wake refused for ${ subscription . id } : ` +
752- `no running process found for userDataDir='${ meta . userDataDir } '. ` +
782+ `no running process found for userDataDir='${ instanceAddress } '. ` +
753783 `Failing closed to avoid misrouting to another ${ appName } instance.`
754784 ) ;
755785 return ;
@@ -945,6 +975,94 @@ async function deliverDigest(subscription, digest) {
945975 return deliveryPromise ;
946976}
947977
978+ /**
979+ * @summary Resolves generic instance-address metadata with legacy field compatibility.
980+ * @param {Object } meta Subscription harnessTargetMetadata.
981+ * @returns {{instanceAddress:String|null,addressType:String|null} }
982+ */
983+ function resolveInstanceAddress ( meta = { } ) {
984+ const addressType = meta . addressType
985+ || ( meta . userDataDir ? 'userDataDir' : null ) ;
986+
987+ const instanceAddress = meta . instanceAddress
988+ || ( addressType === 'userDataDir' ? meta . userDataDir : null ) ;
989+
990+ return {
991+ instanceAddress : instanceAddress || null ,
992+ addressType : addressType || null
993+ } ;
994+ }
995+
996+ /**
997+ * @summary Requires fresh HarnessPresence before immediate address-specific dispatch.
998+ * @param {Object } subscription WAKE_SUBSCRIPTION node.
999+ * @param {Object } address Resolved address tuple.
1000+ * @returns {Boolean }
1001+ */
1002+ function assertFreshTargetPresence ( subscription , { addressType} ) {
1003+ const presence = getActiveHarnessPresence ( db , {
1004+ subscriptionId : subscription . id ,
1005+ agentIdentity : subscription . properties ?. agentIdentity
1006+ } ) ;
1007+
1008+ if ( isHarnessPresenceFresh ( presence ) ) return true ;
1009+
1010+ writeLog ( 'WARN' ,
1011+ `[Bridge Daemon] Targeted wake refused for ${ subscription . id } : ` +
1012+ `addressType='${ addressType } ' requires fresh HarnessPresence. ` +
1013+ `Failing closed; recipient will pick up the unread event on next turn.`
1014+ ) ;
1015+ return false ;
1016+ }
1017+
1018+ /**
1019+ * @summary Normalizes a pid string for direct-pid address dispatch.
1020+ * @param {* } pid Process id candidate.
1021+ * @returns {Number|null }
1022+ */
1023+ function normalizePid ( pid ) {
1024+ const numericPid = Number ( pid ) ;
1025+
1026+ return Number . isInteger ( numericPid ) && numericPid > 0 ? numericPid : null ;
1027+ }
1028+
1029+ /**
1030+ * @summary Posts a wake digest to a bridge-dispatchable webhook address.
1031+ * @param {Object } subscription WAKE_SUBSCRIPTION node.
1032+ * @param {String } digest Wake digest body.
1033+ * @param {String } webhookUrl Target webhook URL.
1034+ * @returns {Promise<void> }
1035+ */
1036+ async function deliverViaWebhookUrl ( subscription , digest , webhookUrl ) {
1037+ let url ;
1038+ try {
1039+ url = new URL ( webhookUrl ) ;
1040+ } catch ( error ) {
1041+ writeLog ( 'ERROR' ,
1042+ `[Bridge Daemon] Webhook wake refused for ${ subscription . id } : ` +
1043+ `invalid webhookUrl instanceAddress. Failing closed.`
1044+ ) ;
1045+ return ;
1046+ }
1047+
1048+ const response = await fetch ( url , {
1049+ method : 'POST' ,
1050+ headers : {
1051+ 'content-type' : 'application/json'
1052+ } ,
1053+ body : JSON . stringify ( {
1054+ subscriptionId : subscription . id ,
1055+ digest
1056+ } )
1057+ } ) ;
1058+
1059+ if ( ! response . ok ) {
1060+ throw new Error ( `webhookUrl POST failed with HTTP ${ response . status } ` ) ;
1061+ }
1062+
1063+ writeLog ( 'INFO' , `[Bridge Daemon] Delivered ${ subscription . id } via webhookUrl POST` ) ;
1064+ }
1065+
9481066// Start loop
9491067async function main ( ) {
9501068 await enforceSingleton ( ) ;
0 commit comments